0x00

리서치 / 일일 리포트

  • 읽은 자료 1건
  • 정본 반영 1개 파일
  • 레이더 0건
한 줄 요약X가 피드 조립 서비스인 홈믹서에서 실험하던 dwell-regret 대체 스코어링과, 그 진입을 정하던 19-피처 유저 게이트를 전부 롤백했다. 점수 계산은 weighted 경로 하나만 남았다.

1홈믹서의 실험용 dwell-regret 대체 스코어링이 롤백됐다 — X

자료 xai-org/x-algorithm@8b25829 — GitHub 커밋, 2026-09-18T23:55:39Z 저자 커밋, 파일 41개, +2,877/−1,828. 이 노트들은 X의 공개 소스 4세대(2026-08-13, 47c1bcd) 뒤에 나온 커밋에 차례로 번호를 붙여 부르며, 이 커밋은 4세대+25다.

배경 어제 다룬 커밋 4세대+24(c279172)보다 22시간 늦게, 같은 날짜(2026-09-18)에 등록된 커밋이다. 삭제 1,828줄이 추가 2,877줄과 대체로 균형을 이루지만, 삭제는 서로 얽힌 세 파일(아래 A 항목)에 몰려 있다. 이번 커밋은 홈믹서의 실험 스코어링 모드 하나를 롤백하면서, 다른 영역 여러 곳도 함께 손봤다.

커밋이 다루는 축은 다섯 갈래다.

  • A. home-mixer(X의 피드 요청을 받아 후보를 모으고 순위를 매기는 서비스)의 랭킹 스코어링에서, 4세대 초기에 들어온 실험 스코어링 모드 하나가 폐기됐다.
    • home-mixer/scorers/value_model_gate.rs 파일 하나가 전부 사라지면서(−537) home-mixer/scorers/ranking_scorer.rs가 −465줄로 크게 정리됐다.
    • home-mixer/params/param.rs는 −110줄로 관련 파라미터 18개를 잃었다.
  • B. Phoenix(X For You의 랭킹과 후보 검색 모델 스택) retrieval에, dataset_type 태그로 구별되는 cold-start 슬라이스가 정식으로 연결됐다.
  • C. Phoenix bundle export가 랭킹 전용에서 two-tower(유저와 포스트를 따로 임베딩해 매칭하는 검색 모델) retrieval까지 지원하도록 확장됐다.
    • Phoenix serving/train 라인에서 recsys_bundle_export.py가 +559/−107로 크게 개편됐다.
  • D. VF(visibility-filtering, 포스트마다 이 뷰어에게 보여도 되는지 판정하는 서비스)에는 어제까지 스텁이던 evaluate_tweets gRPC의 서버 구현이 생겼다. TES(Tweet Entity Service, X 내부의 트윗 메타데이터 서비스)가 넘겨 주던 visibility 필드를 대체하는 하이드레이터도 등장했다. 이 하이드레이터는 Strato(코드가 데이터를 조회하거나 저장할 때 거치는 X 내부 시스템) 컬럼에서 필드를 가져온다.
    • 추가 쪽에서는 두 개의 새 파일 visibility-filtering/evaluate_tweets.rs(+258)와 visibility-filtering/hydration/tes_composite.rs(+607)가 등장했다.
  • E. 인프라 정리로 CUDA 커널이 xrex 리포에서 외부 패키지 xrex_cuda_kernels로 분리됐다. 또 grox(Grok 기반 nearline 분류기 서브레포)의 성인 spam clone 탐지 흐름에 fav-count 기반 reasoning 게이팅이 붙었다.
    • 이 탐지 흐름의 이름은 media_injected_adult_infrared로 시작한다.

다섯 갈래 가운데 A와 D는 4세대에서 그동안 쌓여 온 실험 인프라의 되돌림과 성숙이라는, 서로 다른 방향의 사건이다. A는 4세대 초기에 도입된 별도 스코어링 모드가 실측 결과가 좋지 않아(공개된 근거는 없고, 파일 전체 삭제라는 형태로만 관측된다) 정리되는 흐름이다. D는 어제 4세대+24에서 클라이언트 인터페이스만 열려 있던 스텁이 서버 로직까지 이어지는 성숙 흐름이다.

새로 알게 된 것A. 실험 스코어링 모드 gated dwell-regret과 그 유저 게이트가 전부 롤백됐다

  • 게이트를 거쳐 dwell-regret 스코어링을 쓰던 모드와, 그 유저별 게이트를 정의하던 파일이 전부 롤백됐다 확인.

    • 근거 파일 home-mixer/scorers/value_model_gate.rs는 8b25829에서 존재하지 않는다.
    • 모드는 gated_dwell_regret이고, 파일은 value_model_gate다.
    • 이전 4세대 오픈소스 히스토리에서 이 파일은 GateModel::from_params()를 정의했다.
    • GateModel은 유저 상태 피처 19개를 log1p로 정규화한 뒤, 로지스틱 선형결합으로 게이트 점수를 냈다. 그다음 요청 hash로 hysteresis band 안에서만 배정을 뒤집었다.
    • 피처 목록은 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다.
    • 롤백 커밋의 patch에서 이 로직 전체가 −537줄로 지워진 것이 확인된다.
  • GateModel은 ranking_scorer.rs에서 ValueModelMode 파라미터가 게이팅 모드일 때만 발동했다. 이때 유저별 조건에 따라, 체류 시간(dwell)을 바탕으로 한 dwell-regret 계열 대체 스코어를 쓸지 정했다.

    • ValueModelMode의 값은 세 가지(weighted / dwell_regret_sigmoid / gated_dwell_regret)였다.
  • 8b25829는 이 세 값 갈래를 전부 없앴다.

    -        let use_dwell_regret = match query.params.get(ValueModelMode).as_str() {
    -            DWELL_REGRET_SIGMOID_MODE => true,
    -            GATED_DWELL_REGRET_MODE => GateModel::from_params(&query.params)
    -                .is_some_and(|gate| gate.serve_new_scoring(query)),
    -            _ => false,
    -        };
    

    — home-mixer/scorers/ranking_scorer.rs, 2026-09-18 삭제분 (실제 소스는 사라짐)

  • 대체 로직은 없다. 조건 분기 자체가 제거됐고, weighted_parts와 weighted_scores 계산에는 use_dwell_regret == false 분기 하나만 남는다.

    • 홈믹서 스코어링은 4세대+8(2026-08-25)에서 클라이언트 쪽 head-weight 생성을 서버로 옮긴 뒤 다시 한 번 단순해져, weighted 단일 경로로 정착했다.
  • ranking_scorer.rs에서는 관련 요소도 함께 삭제됐다 확인 — home-mixer/scorers/ranking_scorer.rs (+12/−465).

    • DwellRegretWeights struct와 from_params() 생성자.
      • struct의 튜닝 필드 13개는 alpha_favorite·alpha_reply·alpha_retweet·alpha_quote·alpha_share·alpha_share_via_dm·alpha_share_via_copy_link·neg_not_interested·neg_block_author·neg_mute_author·neg_report·temperature·dwell_floor였다.
    • compute_dwell_regret_base_scores() 함수 전체. 이 함수는 후보 세트 안에서 파라미터별 평균을 낸 뒤, 각 후보의 relative preference를 centered_ratio = p / mean − 1로 만들었다. positive와 negative를 각각 sigmoid(positive/T)와 exp(min(negative,0)/T)로 곱해, 최종 점수를 dwell × modulation으로 냈다. 즉 "dwell 시간을 참여 신호로 modulate하는" 대체 스코어링 함수다.
    • centered_ratio()·sigmoid() 헬퍼.
    • use_dwell_regret 분기에 걸린 MultiplierPreOffset 건너뛰기 로직.
    • 회귀 테스트 7개와 GATE_WEIGHTS_ALL_ZERO 상수, 그리고 기존 회귀 테스트의 "rust_home_mixer_value_model_mode": "weighted" 오버라이드 라인.
      • 테스트 이름은 dwell_regret_neutral_post_scores_dwell·dwell_regret_above_average_likeable_is_boosted·dwell_regret_report_sinks_candidate·dwell_regret_floor_applies_to_low_dwell·dwell_regret_per_request_normalization_amplifies_for_low_engagement_set 등이다.
  • 파라미터 미러에서도 관련 파라미터가 대칭으로 삭제됐다 확인 — home-mixer/params/param.rs (+8/−110).

    • 이 파일은 프로덕션 설정값을 주기적으로 옮겨 오는 미러다. 이번 mirror sync 시각은 2026-09-17T20:46:44Z → 2026-09-18T16:21:20Z로 바뀌었다.
    • 삭제된 param은 18개다.
이름 feature switch 키 기본값
ValueModelMode rust_home_mixer_value_model_mode "weighted"
DwellRegretTemperature ..._dwell_regret_temperature 10.0
DwellRegretDwellFloor ..._dwell_regret_dwell_floor 1.0
DwellRegretAlphaFavorite ..._dwell_regret_alpha_favorite 1.0
DwellRegretAlphaReply ..._dwell_regret_alpha_reply 1.0
DwellRegretAlphaRetweet ..._dwell_regret_alpha_retweet 1.0
DwellRegretAlphaQuote ..._dwell_regret_alpha_quote 1.0
DwellRegretAlphaShare ..._dwell_regret_alpha_share 1.0
DwellRegretAlphaShareViaDm ..._dwell_regret_alpha_share_via_dm 1.0
DwellRegretAlphaShareViaCopyLink ..._dwell_regret_alpha_share_via_copy_link 1.0
DwellRegretNegNotInterested ..._dwell_regret_neg_not_interested −10,000
DwellRegretNegBlockAuthor ..._dwell_regret_neg_block_author −8,000
DwellRegretNegMuteAuthor ..._dwell_regret_neg_mute_author −15,000
DwellRegretNegReport ..._dwell_regret_neg_report −60,000
DwellRegretGateWeights ..._dwell_regret_gate_weights 19-피처 로지스틱 가중치 문자열
DwellRegretGateBias ..._dwell_regret_gate_bias 1.033918
DwellRegretGateThreshold ..._dwell_regret_gate_threshold −0.634264
DwellRegretGateHysteresisBand ..._dwell_regret_gate_hysteresis_band 0.0
  • 롤백된 파라미터의 프로덕션 기본값에서, 게이트가 실제로 학습된 가중치를 담고 있었다는 사실을 확인할 수 있다. DwellRegretGateWeights의 기본값 문자열이 지운 시점에도 남아 있었기 때문이다.

    "seq_len:0.530298,n_fav:-0.082139,n_reply:0.485541,n_rt_quote:0.056561,n_vqv:-0.072778,n_click:-0.176675,n_bm_share:-0.167574,n_profile_follow:-0.221285,n_photo:-0.106004,n_negfb:0.031839,n_7d:-0.075799,n_1d:-0.241730,active_days:0.047017,active_days_7d:-0.126896,days_since_last:-0.034238,span_days:-0.052186,followers:-0.066642,followings:0.064140,account_age_years:-0.045455"

    • seq_len(현재 세션 시퀀스 길이)과 n_reply(28일 리플라이 수) 두 축에는 강한 양의 가중치가 걸려 있었다.
    • 나머지 상호작용 카운트(n_click·n_bm_share·n_profile_follow·n_1d)에는 음의 가중치가 걸려 있었다.
    • 즉 "롱 세션 활동 사용자이면서 최근 리플라이 활동이 강한" 유저에게 dwell-regret 대체 스코어링을 서빙하는 것이 목표였던 것으로 읽힌다.
    • 이 계수 자체는 이제 어느 유저에게도 적용되지 않는다.

4세대에서 랭킹 스코어링 옆에 나란히 있던 실험적 대체 함수(dwell 시간을 참여 신호로 modulate)와 그 유저 게이팅이 한 번에 전부 제거됐다. 랭킹 스코어링은 다시 weighted 단일 경로로 좁혀졌다. 이 자리에서 dwell 신호를 어떻게 반영할지는 훈련 시점의 헤드 신설과 점수 결합 재정의로 넘어간 것으로 보인다 추정. 4세대+22에서 정식으로 연결된 click_dwell_time sigmoid BCE(threshold=10s)와 4세대+23의 EnableCdwellOnImpr(click × dwell 곱)가 그 예다 추정. 근거는 두 흐름의 시점이 인접하다는 점, 그리고 dwell을 별도 스코어링 함수로 modulate하던 회로가 사라지면서 dwell을 헤드 자체와 조합 항으로 다루는 회로만 남는다는 관측이다.

새로 알게 된 것B. Phoenix retrieval에 cold-start 슬라이스가 연결됐지만, 기본값으로는 요청하지 않는다

  • Phoenix retrieval에 전용 태그로 구별되는 cold-start 슬라이스가 정식으로 연결됐다 확인.

  • 새 파라미터 PhoenixColdStartMaxResults가 추가돼, retrieval RPC를 호출할 때 두 상한이 함께 넘어간다.

    • feature switch는 rust_home_mixer_phoenix_cold_start_max_results이고, 기본값은 0이다.
             quality_factor::apply(query.params.get(PhoenixMaxResults)),
    +        query.params.get(PhoenixColdStartMaxResults),
    
    • 기본값이 0이므로 프로덕션 기본은 여전히 cold-start 슬라이스를 요청하지 않는다.
  • 응답 후보가 dataset_type을 실어 오고, 그 값에 따라 served_type(후보에 붙는 출처 라벨)이 갈린다.

    • 응답 후보(ScoredCandidate)가 dataset_type: u32 필드를 실어 온다.
    • 그 값이 HOME_COLD_DATASET_TYPE = 13이면 served_type이 새 enum ForYouPhoenixRetrievalCold로 매핑되고, 그렇지 않으면 기존 ForYouPhoenixRetrieval로 매핑된다.
    const HOME_COLD_DATASET_TYPE: u32 = 13;
    
    fn served_type_for_dataset(dataset_type: u32) -> pb::ServedType {
        if dataset_type == HOME_COLD_DATASET_TYPE {
            pb::ServedType::ForYouPhoenixRetrievalCold
        } else {
            pb::ServedType::ForYouPhoenixRetrieval
        }
    }
    

    — home-mixer/sources/phoenix_source.rs

  • side effect에도 대칭으로 관측 라인이 붙었다 확인 — home-mixer/side_effects/scored_stats_side_effect.rs (+25/−0).

    • 새 함수 record_phoenix_retrieval_cold_stats가 PhoenixRetrievalColdTweets라는 접두로 selected/non-selected candidates의 크기 통계를 stat receiver에 발행한다.
    • 기존 MoE 슬라이스(PhoenixRetrievalMoeTweets)와 나란한 세 번째 관측 축이다. MoE(Mixture of Experts)는 입력마다 여러 전문가 서브네트워크 가운데 일부를 골라 쓰는 모델 구조다.
  • MoE 소스와 Topics 소스는 이 cold-start 슬라이스 로직을 공유하지 않는다(patch에서 확인).

    • phoenix_moe_source.rs와 phoenix_topics_source.rs도 각각 1줄씩 늘었지만, 둘 다 새 import 라인 추가다.
    • 두 소스는 기존대로 ForYouPhoenixRetrievalMoe·ForYouPhoenixRetrievalTopics served_type을 그대로 쓴다.

지금까지 X의 콜드스타트 처리는 두 축이었다.

  • 이력이 임계 미만인 유저에게는 별도 모델 플릿을 라우팅한다. retrieval과 랭킹에 각각 Phoenix*NewUserInferenceClusterId가 있다.
  • 신규 작성자(ColdStartImpressionThreshold=1000 이하)에게는 PostUnexploredWeight=0.02 부스트를 주고, 4세대+1의 Thompson sampling 옵션을 둔다.

이번 연결은 후보 수준의 세 번째 축을 연다. 같은 Phoenix retrieval RPC가 돌려주는 후보 세트 안에서, dataset_type 태그로 "cold" 서브셋에 따로 상한을 두고 관측한다. 즉 Phoenix retrieval 자체가 유저 이력과 무관하게 콜드 후보 슬라이스를 항상 함께 반환하도록 확장된 것으로 보인다 추정. 근거는 기본값이 0이라 프로덕션 기본에는 아직 들어가지 않았지만, 관측용 stat 라인까지 대칭으로 연결된 것으로 보아 A/B 인프라가 완성돼 있다는 점이다.

새로 알게 된 것C. Phoenix 번들 export가 랭킹에 이어 two-tower retrieval의 forward 프로그램도 내보낼 수 있게 됐다

  • Phoenix bundle export가 랭킹 forward에 이어 two-tower retrieval forward까지 export할 수 있게 확장됐다 확인.

    • 근거 파일은 phoenix/xrex/train/recsys_bundle_export.py(+559/−107)다.
    • 이 파일은 4세대+13(2026-09-01, 7ba7768)에서 634줄로 신설됐다. 랭킹 모델(RecsysAggregatedModelConfig)을 jax.export로 낮춰 StableHLO MLIR bytecode, .jax_export, MANIFEST.json으로 export하는 용도였다.
    • 이번 커밋이 여기에 retrieval two-tower 프로그램 export를 나란히 붙였다. 새로 등장한 축은 두 가지다.
  • 축 1: 프로그램 종류가 두 개로 늘었다 확인. 두 상수가 export bundle 안에서 프로그램의 kind를 구별한다.

    KIND_RANKING = "recsys_ranking_forward"
    KIND_RETRIEVAL = "recsys_retrieval_forward"
    
  • 축 2: retrieval export는 추가 리소스를 번들에 실어야 하므로 새 자료구조와 wire key들이 도입됐다.

    POST_TABLE_KEY = "post_embeddings.embeddings"
    POST_DATASET_TYPES_KEY = "post_embeddings.dataset_types"
    POST_IDS_KEY = "post_embeddings.post_ids"
    AUTHOR_IDS_KEY = "post_embeddings.author_ids"
    
    • 새 dataclass RetrievalExport는 export 시점에 트레이너의 RetrievalModelRunner에서 뽑아내야 하는 5개 속성을 담는다. 속성은 large_k·retrieval_dataset_types·enable_async_topk·enable_radix_select_topk·enable_dataset_slice_topk다.
    • 매니페스트 블록에는 아래 항목이 실린다.
      • large_k
      • target dataset types(name·value 페어의 리스트)
      • post table 지오메트리(rows·width·dtype)
      • 세 자료 키(POST_TABLE_KEY·POST_DATASET_TYPES_KEY·POST_IDS_KEY·AUTHOR_IDS_KEY)
      • topk_unordered 플래그
  • retrieval forward는 유저 타워로 유저 임베딩을 뽑고, 이를 post table 전체와 매칭해 target dataset type마다 top-K를 반환한다 확인 — 새 함수 _make_retrieval_forward_fn.

    def forward_fn(
        batch: Any, merged_embeddings: jax.Array, post_table: jax.Array, dataset_types: jax.Array
    ):
        recsys_embeddings = _recsys_embeddings_from_merged(
            merged_embeddings, embedding_slices, packed_geometry
        )
        model = model_config.make(sharding_context=make_legacy_sharding_context(mesh))
        results = model.forward(
            batch, recsys_embeddings, post_table, dataset_types,
            retrieval.large_k, target_values,
            dataset_ranges=None,
            use_async_topk=retrieval.use_async_topk,
            use_radix_select_topk=retrieval.use_radix_select_topk,
            post_scales=None,
        )
        flat: list[jax.Array] = []
        for indices, scores in results:
            flat.append(indices)
            flat.append(scores)
        return tuple(flat)
    
    • target dataset type마다 출력이 두 개(indices_<name>, scores_<name>) 나온다. 이 이름 규칙은 RetrievalExport.output_names property에 정의돼 있다.
  • two-tower일 때는 서빙 시퀀스 길이를 계산하는 규칙이 다르다.

    • _make_export_config에도 two_tower: bool = False와 mesh_devices: int = 1 두 인자가 추가됐다.
    def _user_tower_sequence_len(
        *, training_seq_len, training_history_seq_len, num_user_prefix_tokens, history_seq_len,
    ) -> int:
        prefix_tokens = training_seq_len - training_history_seq_len
        if prefix_tokens != num_user_prefix_tokens:
            raise ValueError(
                f"cannot derive the serving user-tower sequence_len: sequence_len "
                f"{training_seq_len} - history_seq_len {training_history_seq_len} = {prefix_tokens} "
                f"prefix tokens, but the model reserves num_user_prefix_tokens="
                f"{num_user_prefix_tokens}. The user tower is built as prefix + history; if "
                "history_seq_len was overridden for serving, also override "
                "model_config.user_tower_config.model_config.sequence_len to "
                f"{num_user_prefix_tokens + history_seq_len} (prefix + serving history), as the "
                "jax slot does — otherwise the attention kernel cannot be sized for serving"
            )
        return prefix_tokens + history_seq_len
    
    • 랭킹 export의 서빙 시퀀스는 prefix + history + candidate이지만, retrieval export는 prefix + history뿐이다. candidate 축이 없고, 유저 임베딩만 낸다.
    • _make_export_config의 overrides도 model_config.model_config.sequence_len 대신 model_config.user_tower_config.model_config.sequence_len을 쓴다.
  • 세 번째로 새로 등장한 것은 분산 export다. retrieval bundle은 mesh 위에서 export되어 여러 장치에 분산될 수 있다.

    if not two_tower and mesh_devices != 1:
        raise NotImplementedError(
            "ranking programs are single-device (one executable per GPU); "
            f"mesh_devices={mesh_devices} is only for retrieval bundles"
        )
    
    • 이유도 명시돼 있다. 랭킹은 GPU마다 executable 하나로 서빙되지만, retrieval은 post table을 여러 장치에 나눠 담아야 하므로 SPMD로 export한다.
    • _export_mesh는 export_cfg.parallel_config.mesh_shape()을 확인해 device를 만든다. _retrieval_shardings는 batch/params/post_table/dataset_types에 대한 NamedSharding을 지정한다.
    • 임베딩 rows는 data axis로 분산 파티션되고, params는 replicated된다.
  • 몇 가지 제약이 명시적으로 걸린다.

    • enable_int8_post_table·enable_bloom_filter·enable_topic_filter는 지원하지 않는다. 이 옵션들은 native runtime만 지원하고, StableHLO export는 bf16 post table과 no-per-post-filter 구성이다.
    • enable_dataset_slice_topk=True는 무시되고 경고가 뜬다. export 프로그램은 항상 mask-then-topk-over-whole-table 경로만 컴파일한다.
    • using_seqpack + two_tower 조합은 금지된다. packing이 SPMD 모델과 맞지 않는다.
    • DEBUG_ALLOW_RANDOM_INIT=1이면 export 자체를 거부한다. 마스크 없이 export되면 유효하지 않은 프로그램이 나가기 때문이다.
  • build_bundle이 이제 두 종류 모델을 모두 받는다.

    two_tower = isinstance(model_config, RecsysTwoTowerModelConfig)
    if not two_tower and (
        not isinstance(model_config, RecsysAggregatedModelConfig)
        or isinstance(model_config, RecsysGenRecsModelConfig)
    ):
        raise NotImplementedError(
            "StableHLO bundle export supports ranking (RecsysAggregatedModelConfig) and "
            f"retrieval (RecsysTwoTowerModelConfig) only, got {type(model_config).__name__}"
        )
    

지금까지 Phoenix serving은 랭킹 forward만 StableHLO 번들로 실었다. 이 커밋으로 retrieval two-tower도 같은 번들 프레임에 실릴 수 있다는 준비가 끝났다. retrieval 쪽을 SPMD로 여러 장치에 분산해 export할 수 있는 인프라까지 함께 붙었기 때문에, Phoenix retrieval의 서빙 스택이 랭킹과 같은 번들 규격으로 통일되는 것이 방향이다. 위 B의 PhoenixColdStartMaxResults 연결과 시점이 같이 붙은 것도 우연이 아닐 수 있다 추정. retrieval 서빙 축이 조여지는 시기에 dataset_type 태그를 통한 슬라이스 관측 인터페이스가 함께 정착한 셈이다 추정.

새로 알게 된 것D. VF가 판정 요청을 받는 서버를 갖췄고, 판정용 visibility 필드를 TES 대신 Strato 컬럼에서 받는다

이 축은 두 하위 축으로 나뉜다.

D1. 어제까지 스텁이던 판정 요청 RPC를 이번 커밋이 서버 로직으로 채웠다.

  • daily/2026-09-18.md에는 2026-09-17의 4세대+23(42266f3)이 evaluate_tweets RPC의 클라이언트 인터페이스와 응답 payload Thrift 디코더만 열어 두었다는 관측이 남아 있다. 같은 관측에 따르면 서버 구현은 Err(Status::unimplemented("EvaluateTweets is not served yet"))였다.

  • 이번 커밋은 그 evaluate_tweets 스텁을 서버 로직으로 채운다. server.rs patch에서 unimplemented 스텁이 실제 구현으로 바뀌는 것이 직접 확인된다.

         async fn evaluate_tweets(
             &self,
    -        _: Request<vf_pb::EvaluateTweetsRequest>,
    +        request: Request<vf_pb::EvaluateTweetsRequest>,
         ) -> Result<Response<vf_pb::EvaluateTweetsResponse>, Status> {
    -        Err(Status::unimplemented("EvaluateTweets is not served yet"))
    +        self.evaluate_tweets.handle(request).await
         }
    
  • 새 서버 엔드포인트 EvaluateTweetsEndpoint(258줄 신규)는 요청을 받아 내부 필터를 돌리고, 응답을 tweet별 Outcome으로 채운다.

    • request 3개 필드(viewer_id·country_code·safety_level 그리고 tweet 리스트)를 받는다.
    • safety_level은 Thrift enum에서 내부 Rust SafetyLevel enum으로 변환한다. 허용하는 값은 FILTER_ALL·TIMELINE_HOME·TIMELINE_HOME_RECOMMENDATIONS 3개뿐이다.
    • 그런 뒤 FilterTweets::run을 호출한다.
    let candidates = req.tweets.iter()
        .filter(|o| o.quote_context.is_none())
        .map(|o| RawCandidate { tweet_id: TweetId(o.tweet_id), request_author_id: None })
        .collect();
    let response = self.filter_tweets.run(FilterRequest { viewer_id, country_code, safety_level, candidates }).await;
    
  • 요청마다 메트릭 3종(evaluate_tweets_requests·evaluate_tweets_latency_ms·evaluate_tweets_batch_size)이 기록된다.

  • evaluate_tweets_loopback 통합 테스트가 서버 로직이 연결됐다는 사실을 고정한다. 이 테스트는 tokio TCP listener 위에 gRPC 서버를 띄우고 XaiVfClient로 왕복 호출한다.

D2. 판정 결과에 verdict 말고도 평가 상태가 함께 붙는다.

  • FilterOutcome에 EvaluationStatus 축이 추가됐다. filter.rs에 새 enum이 등장한다.

    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    pub enum EvaluationStatus {
        Evaluated,
        UnresolvedAuthor,
        Failed,
    }
    
  • 이전에는 filter가 tweet마다 Verdict 하나만 낼 수 있었다. 이제 verdict 옆에 세 상태 중 하나가 붙는다.

    • Hydration이 성공한 tweet은 Evaluated다.
    • author가 hydration에서 나오지 않은 tweet은 UnresolvedAuthor이고, 이때 verdict는 Verdict::unresolved_author()다.
    • hydration 자체가 실패해서 필드가 부족한 상태로 rule에 걸린 tweet은 Failed다.
    • HydrationOutput에도 새 필드 failed_ids: HashSet<TweetId>가 추가돼 세 번째 경우(Failed)를 구별한다.
  • 클라이언트 쪽 EvaluationResult { Evaluated(Box<Action>), NotEvaluated, Failed }(4세대+23 도입)와 축 이름이 나란히 나오고, 서버와 클라이언트의 축이 다음처럼 맞물린다 추정.

    • EvaluationStatus::UnresolvedAuthor + Verdict::unresolved_author() → EvaluationResult::NotEvaluated
    • EvaluationStatus::Failed → EvaluationResult::Failed
    • EvaluationStatus::Evaluated + Verdict::* → EvaluationResult::Evaluated(Action)
    • 추정으로 두는 이유는 patch에 매핑이 직접 나오지 않고 EvaluateTweetsResponse 조립부만 나오기 때문이다. 다만 두 축의 값 이름이 대칭이다.

D3. TES가 맡던 visibility 필드 7종이 별도 Strato 컬럼 하이드레이터로 옮겨 갔다.

  • tes_hydrator.rs가 −320줄로 크게 줄었고, 지운 자리 대부분이 TweetHydration struct다.

    -#[derive(Default)]
    -pub(crate) struct TweetHydration {
    -    pub(crate) nullcast: TweetHydrationBatch<bool>,
    -    pub(crate) community: TweetHydrationBatch<i64>,
    -    pub(crate) nsfw_user: TweetHydrationBatch<bool>,
    -    pub(crate) nsfw_admin: TweetHydrationBatch<bool>,
    -    pub(crate) takedown_reasons: TweetHydrationBatch<Vec<TakedownReason>>,
    -    pub(crate) edit_control: TweetHydrationBatch<EditControl>,
    -    pub(crate) media: TweetHydrationBatch<MediaFeature>,
    -}
    
  • 이 7개 축은 TweetHydrationBatch<TweetForVisibility> 하나로 통합됐다. TweetForVisibility는 새 파일 tes_composite.rs에 정의된 11-필드 struct다.

    pub(crate) struct TweetForVisibility {
        pub(crate) author_id: u64,
        pub(crate) source_tweet_id: Option<u64>,
        pub(crate) is_nullcast: bool,
        pub(crate) nsfw_user: bool,
        pub(crate) nsfw_admin: bool,
        pub(crate) has_takedown: bool,
        pub(crate) takedown_reasons: Vec<TakedownReason>,
        pub(crate) media: MediaFeature,
        pub(crate) is_community_tweet: bool,
        pub(crate) edit_control: Option<EditControl>,
        pub(crate) exclusive_conversation_author_id: Option<u64>,
    }
    
  • 이 데이터를 가져오는 소스는 TES가 아니라 Strato 컬럼의 fetch operation이다 확인 — visibility-filtering/hydration/tes_composite.rs.

    • 컬럼은 tweetypie/federated/tweetForVisibility.Tweet이고, operation은 fetch다.
    const COLUMN: &str = "tweetypie/federated/tweetForVisibility.Tweet";
    const OPERATION: &str = "fetch";
    
    • ProdTweetForVisibilitySource::get_tweets_for_visibility가 tweet_id 목록을 이 컬럼의 batch call로 보낸다. 응답은 MVal(Strato)로 디코드하고, Thrift 정의 Tweet을 TweetForVisibility::project()로 눌러 담아 돌려준다.
    • 즉 TES와 별개인 VF 전용 Strato 컬럼 인터페이스가 열렸다.
  • TesHydrator는 여전히 존재하지만, 이제 두 소스를 오케스트레이션한다.

    • TES에서는 여전히 PureCoreData(author_id·text·source_tweet_id 등 corpus 차원 신호)를 받는다.
    • TweetForVisibilitySource에서는 visibility rule이 요구하는 flag/label 축을 받는다.
    • 즉 corpus 데이터와 visibility 데이터가 서로 다른 Strato 백엔드로 갈렸다.

VF는 지금까지 TES의 광역 tweet 필드를 그대로 참조해 왔다. 이제 그 관행에서 벗어나, visibility 규칙에 필요한 최소 필드 집합만 별도 Strato 컬럼으로 조회하는 방향이다. 세 축이 함께 정착했다.

  • 4세대+13에서 VF rules가 tweet/author 2분류로 재구조화됐다.
  • 4세대+23은 자체 twemcache 서브트리 3,234줄을 전부 지우고 회사 공용 xai_cache로 옮기면서, evaluate_tweets 클라이언트 인터페이스를 스텁으로 열었다.
  • 오늘 서버 구현과 TES 분리가 완성된다.

즉 4세대에서 VF는 rules → dependencies → serving interface 순서로 세 층이 차례로 재편됐다.

새로 알게 된 것E. grox의 성인 스팸 영상 탐지가 좋아요 수가 임계치 이상인 포스트에서만 reasoning 모드를 켠다

  • grox 탐지 흐름에 fav-count(좋아요 수) 게이팅으로 reasoning 모드가 붙었다 확인.

    use_reasoning = post.get_fav_count() >= MEDIA_INJECTED_REASONING_FAV
    ...
    prompt = media_injected_adult_infrared_video_spam_detection_prompt()
    system_prompt = (
        _strip_thinking_restrictions(prompt) if use_reasoning else prompt
    )
    ...
    assistant_content = [] if use_reasoning else [THINKING_CONTROL_END]
    
  • Fav count가 임계치 이상인 포스트에는 모델이 명시적으로 생각하는 경로가 열린다.

    • 시스템 프롬프트의 "thinking restrictions"를 벗겨 내고, assistant 응답의 THINKING_CONTROL_END 마커를 비워 둔다.
  • 임계치 MEDIA_INJECTED_REASONING_FAV의 값은 grox/flows/ptos/constants.py에 들어갔지만, patch에는 값이 노출되지 않았다.

  • 4세대+7(2026-08-24)의 SafetyPtosPolicyCrossValidator는 USE_REASONING_CATEGORIES = {ViolentSpeech, SuicideOrSelfHarm} 카테고리에만 reasoning을 켰다. 이번에는 fav count로 개별 포스트 축에서 reasoning을 게이팅한다.

이 게이팅은 비용을 fav count 상위 스레드에만 쓰기 위한 전형적인 이원 분기다. 4세대+22(2026-09-16)의 oai-gemma4-26b-reply-spam 이원 분기(150k 팔로워 초과 스레드)와 같은 계열이다. 4세대+12(2026-09-01)의 deluxe_fav_count_threshold special-video 진입 게이트도 같은 계열이다.

새로 알게 된 것그 밖의 변경

  • Phoenix retrieval의 MoE inference cluster ID가 롤포워드됐다 확인.
    • 근거는 param.rs이고, 값은 Experiment2Memy04 → Experiment3Memy04로 바뀌었다.
  • CUDA 커널 3개의 로더가 레포 안 소스 대신 외부 패키지 xrex_cuda_kernels를 불러오도록 바뀌었다 확인 — phoenix/xrex/cuda/adler32/__init__.py(+21/−6).
    • 나머지 파일: phoenix/xrex/cuda/top_k_by_key/__init__.py(+26/−6), phoenix/xrex/cuda/unique/__init__.py(+15/−5), phoenix/xrex/cuda/__init__.py(+11/−4)

    • 지금까지는 from xrex.cuda.<name>.src import <name>_api(레포 안 subfolder)로 import했다. 이제는 import xrex_cuda_kernels.<name>_api as <name>_api(외부 패키지)로 바뀐다. 예를 들어 adler32/__init__.py는 이렇게 바뀌었다.

      try:
          import xrex_cuda_kernels.adler32_api as adler32_api
      except ModuleNotFoundError:
          adler32_api = None
      else:
          jax.ffi.register_ffi_target("xrex_adler32", adler32_api.adler32(), platform="CUDA")
          _FFI_BACKENDS.add("gpu")
      
    • 모듈이 없으면 예외를 던지지 않고 조용히 fallback한다.

      if _MISSING:
          _log.warning(
              "xrex.cuda.adler32: xrex_cuda_kernels.%s not installed; the zlib pure_callback "
              "reference path runs instead of the compiled kernel(s)",
              ", ".join(_MISSING),
          )
      
    • 패키지가 설치되지 않은 환경에서는 zlib의 pure_callback 순수 파이썬 참조 경로가 대신 돈다. top_k_by_key와 unique도 같은 패턴이다.

    • 지금까지 xrex 리포 안에는 CUDA 커널 소스(src/)까지 담겨 있었다. 이 커밋으로 커널 바이너리가 별도로 설치되는 파이썬 패키지(오픈소스 릴리즈에는 미포함)로 분리됐다.

    • 오픈소스 사용자는 참조 경로로 돌리고, 프로덕션 xrex 환경만 컴파일된 커널을 쓰는 구조다. 4세대+21(2026-09-15)이 H100 attn 커널을 pallas_ranker_varlen_attn → cutedsl_ranker_varlen_attn으로 바꾼 것과 같은 방향의 정리다.

정본 반영 ../services/x.md의 상위 timeline 표에 4세대+25 행을 추가하고, 하단에 상세 절을 새로 만들었다.

의미 어제 커밋(4세대+24)까지 누적 추가 방향으로 움직이던 X 오픈소스가, 오늘 4세대 들어 처음으로 되돌림을 함께 실었다. 또 4세대는 세 축에서 "성숙"하고 있다.

  • 롤백된 value_model_gate(19-피처 유저 게이트)와 dwell_regret_sigmoid·gated_dwell_regret 두 스코어링 모드는 4세대 초기에 도입됐다. 이들은 4세대+8(2026-08-25)의 클라이언트 쪽 head weight 제거와 함께 존재해 왔지만, 실측 지표에서 롤포워드할 이유가 사라진 것으로 보인다(공개된 A/B 결과는 없다).
  • 롤백된 것은 정본에 없던 실험 경로였으므로, 정본에는 새 진술이 아니라 "이 축이 시도됐다가 정리됐다"는 관측만 남는다.
  • 성숙하는 첫째 축은 Phoenix retrieval의 dataset_type 축(위 B)이다. 서버는 dataset_slice_topk까지 완성돼 있고, 오픈소스에는 후보 슬라이스 상한과 관측 stat만 노출됐다.
  • 둘째 축은 Phoenix bundle export(위 C)다. 랭킹만 실을 수 있던 규격에서 retrieval two-tower까지 담을 수 있게 확장됐고, SPMD 분산 export까지 붙었다.
  • 셋째 축은 VF(위 D)다. rules(4세대+13) → deps(4세대+23) → serving interface(오늘)의 순서로 세 층이 정리됐다. 3세대까지 VF가 "라벨 소비자"였다면, 4세대의 VF는 "라벨을 저장·조회·서빙하는 별개 서비스"로 형태가 잡히고 있다.

검증에서 뒤집힌 것

어제 daily가 예측한 판정 서비스의 서버 구현이 오늘 확인됐고, 같은 daily의 자료 요약에서 커밋 귀속 오류를 찾았다.

  • 어제 daily(2026-09-19.md)의 ## 다음에 볼 것 절 예측이 부분적으로 확인됐다. "VF evaluate_tweets RPC의 서버 측 구현" 항목이 오늘 커밋에서 정확히 그 서버가 구현되는 것으로 확인됐다(위 D1).
    • 다만 어제 daily의 상단 자료 요약은 evaluate_tweets를 c279172(어제 커밋)에 잘못 귀속시켰다. 실제로 c279172에서 VF는 visibility-filtering/hydration/gizmoduck_hydrator.rs 2줄 추가만 있었다(gh api 확인).
    • evaluate_tweets 클라이언트 스텁은 4세대+23(2026-09-17, 42266f3)에 실렸고, daily/2026-09-18.md는 이를 정확히 42266f3로 인용했다.
    • 정본에는 영향이 없다. x.md의 4세대+23 행이 이미 42266f3를 명시한다.
  • 홈믹서 스코어링에 ValueModelMode 실험이 별도로 있었다는 사실은 정본(services/x.md)에는 없었고, daily/2026-09-10.md 한 곳에서만 언급됐다. 롤백된 지금 시점에는 이 축을 정본에 다시 반영할 필요가 없다.

다음에 볼 것

레이더

오늘 신규 논문 30건을 triage했고, 제목·초록으로 볼 때 세 조건을 함께 만족하는 것은 없다. 세 조건은 플랫폼 기업 저자, 프로덕션 배포·A/B 결과 주장, 랭킹·retrieval 메커니즘 신규성이다. 대상은 arXiv cs.IR이고, 집계 기준은 파싱된 <entry> 개수 30, 기간 2026-09-15~09-17이다. 이 기간은 어제 daily가 다룬 창과 대부분 겹친다. 잠깐 검토할 만해 보였던 항목 몇 개도 학술 증분에 그친다.

  • "Single-Token Expected-Value Scoring for Cold-Start Candidate Ranking"(2026-09-16): 제목은 콜드스타트 랭킹이지만 저자 소속이 학계이고, abstract에도 프로덕션 진술이 없다. 통과시키지 않았다.
  • "SURF: Subtractive Updates for Recommender Forgetting"(2026-09-16): 삭제 요청 대응(unlearning) 방향이고, 프로덕션 배포 진술이 없다.
  • "One-Step Retrieval Framework for Real-Time Sponsored Search Ads Using Hierarchical Text Representations"(2026-09-16): 플랫폼 스폰서 검색 광고 retrieval이지만 회사 소속은 확인이 필요하다. 초록만으로는 A/B 진술이 명시적이지 않다. 관찰은 유보한다.
  • "LIGE-GR"(2026-09-16)는 이미 어제 radar에 편입됐다.

일요일이므로 산업 블로그 훑기는 건너뛰었다(월요일에만 한다).

통과 0건이다.