0x00

리서치 / 일일 리포트

  • 읽은 자료 1건
  • 정본 반영 1개 파일
  • 레이더 0건
한 줄 요약X가 For You 슬레이트의 다양성 재정렬(DPP)에 기준 포스트를 넣을 수 있게 했다. 이 포스트와 비슷한 후보는 결과에서 빠질 확률이 올라간다.

1다양성 재정렬이 기준 포스트와 비슷한 후보를 밀어낼 수 있게 됐다 — X

자료 xai-org/x-algorithm@6bb4594 — 공개 소스코드, 2026-09-12, 파일 24개, +1397/−338. 이 노트들은 4세대 공개(2026-08-13) 뒤의 X 커밋에 차례로 번호를 붙여 부르며, 이 커밋은 4세대+20이다.

배경 X는 2026-08-13 4세대 공개 이래 For You 파이프라인의 소스코드를 xai-org/x-algorithm에 매일 한두 번 미러링한다. 앞선 4세대+19(fee1d0f, 2026-09-10)는 hydration timeout을 통일했다. 이번 커밋(2026-09-12, 6bb4594)은 그와 흐름이 이어지는 중형 diff로, 지금까지 정본에 쌓인 다섯 축을 한꺼번에 건드린다. 랭킹 재정렬, 로깅·학습 파이프라인, 인프라 이관, 랭킹 모델 Phoenix의 wire 확장, 노출 판정 서비스 VF의 정리·정책이다. 다섯 축은 서로 독립이지만, 한 커밋으로 묶여 올라온 점에서 대략 같은 시기의 프로젝트라는 신호다.

A. 요청이 기준 포스트를 주면, 그 포스트와 비슷한 후보는 DPP 결과에서 빠질 확률이 올라간다

For You는 최종 배치 직전에 슬레이트(한 번에 내보낼 후보 묶음)의 순서를 DPP로 다시 정한다. DPP(determinantal point process)는 서로 비슷한 항목이 함께 뽑히지 않도록 후보 집합 전체를 보고 고르는 방법이다. 이 단계는 지금까지 후보 집합 안에서 후보끼리의 코사인 반발만 계산했다. 이제 요청이 seed_tweet_id로 기준 포스트(seed)를 실어 주면, 다른 후보와 seed의 유사도도 함께 계산한다. seed 자체는 최종 결과에서 걸러지므로 top_k 예산은 그대로다. 다양성 축이 집합 내부에서 참조 tweet 대비로도 확장될 수 있게 된 것이다.

  • 이 단계를 맡는 것은 VMRanker DPP(θ=0.65, K=150)다.

  • seed_tweet_id가 가리키는 tweet의 임베딩이 kernel에 고정(pinned)으로 들어간다.

  • vm-ranker/dpp.rs의 rescore 시그니처가 새 seed 인자를 받도록 확장됐다.

    pub fn rescore(
        inputs: &[DppInput],
        seed: Option<&DppInput>,
        config: &DppConfig,
        _viewer_id: u64,
    ) -> Vec<DppResult> { ... }
    
  • 새 경로는 다음 4단계로 동작한다.

    1. kernel을 seed 하나만큼 키운다. 기존 top-K 후보(m = min(inputs.len(), max_selected_rank))에 seed 하나를 더해 total = m + 1 크기의 kernel을 만든다. seed에는 score가 없으므로 q 값을 임의로 1.0으로 고정한다.
    2. greedy DPP의 첫 슬롯에 seed를 고정한다. greedy_dpp는 이전에 kernel diagonal이 가장 큰 인덱스를 시작점으로 골랐다. 이제 새 인자 pinned_first: Option<usize>가 주어지면 그 인덱스를 강제 첫 선택으로 쓰고, 이 인덱스가 seed의 위치다.
    3. 예산을 늘린다. pinned가 있으면 max_items = (k + 1).min(n)으로 늘려서 seed가 top_k를 잡아먹지 않게 한다.
    4. 결과에서 seed를 거른다. 최종 리턴 전에 dpp_result.selected.iter().filter(|&(ki, _)| ki < m)로 seed 인덱스를 배제한다.
  • 이 4단계로 나타나는 순 효과는 두 가지 계약이다.

    • seed의 임베딩과 가까운 후보는 kernel의 오프대각 항이 커져 결정성(determinantal) 이득이 감소하고, 결과에서 배제될 확률이 올라간다.
    • seed 자체는 결과에 나타나지 않고 top_k도 소진하지 않는다.
  • 새 테스트 두 개가 이 계약을 검증한다.

    #[test]
    fn seed_suppresses_near_duplicate() {
        let seed = make_input(999, 0.0, &[1.0, 0.0]);
        let inputs = vec![
            make_input(1, 10.0, &[1.0, 0.0]),   // seed와 정확히 같은 방향
            make_input(2, 5.0, &[0.0, 1.0]),    // 직교
        ];
        // seed 없이 rescore하면 ids에 1과 2가 모두 들어감
        // seed와 함께 rescore하면 1은 배제, 2만 살아남고 999도 결과에 없음
    }
    
    #[test]
    fn seed_does_not_consume_top_k_budget() {
        let seed = make_input(999, 0.0, &[0.0, 0.0, 1.0]);
        let inputs = vec![
            make_input(1, 10.0, &[1.0, 0.0, 0.0]),
            make_input(2, 5.0,  &[0.0, 1.0, 0.0]),
        ];
        let config = DppConfig { top_k: 2, ... };
        let result = rescore(&inputs, Some(&seed), &config, 0);
        assert_eq!(result.len(), 2, "seed must not eat top_k budget");
    }
    
  • seed의 임베딩이 서빙 시점 스토어에 없으면 pinning 없이 기존 경로로 흐른다.

    • 새 파라미터를 채우는 곳은 vm-ranker/scoring/dpp_model.rs의 rank다.

      fn build_seed_input(req: &RankRequest, ctx: &DppContext) -> Option<DppInput> {
          if req.seed_tweet_id == 0 { return None; }
          match ctx.store.client.get(req.seed_tweet_id) {
              Some(embedding) => {
                  DPP_SEED_CONTEXT.with_label_values(&["pinned"]).inc();
                  let norm = l2_norm(&embedding);
                  Some(DppInput {
                      id: req.seed_tweet_id,
                      score: 0.0,
                      embedding,
                      norm,
                      embedding_missing: false,
                  })
              }
              None => {
                  DPP_SEED_CONTEXT.with_label_values(&["embedding_missing"]).inc();
                  None
              }
          }
      }
      
    • vm_ranker_dpp_seed_context_total{result="pinned"|"embedding_missing"} 카운터가 hit와 miss를 나눠 관측한다.

  • 임베딩 norm이 0에 가까워 kernel diagonal이 EPSILON 이하이면 pin을 포기하고 기존 greedy 경로로 돌아간다.

    pinned = pinned_first.filter(|&p| kernel[p * n + p] > EPSILON); — vm-ranker/dpp.rs

이 경로가 어디에 쓰일지는 이번 커밋만으로는 확정할 수 없다. seed_tweet_id를 실어 주는 홈믹서(home-mixer, For You 피드를 조립하는 서비스) 파이프라인은 이번 diff에 없다. 자연스러운 소비처로는 다음 같은 후보가 있다 추정.

  1. permalink 페이지의 related tweets: "이 tweet과 이질적인" 슬레이트
  2. 특정 tweet의 "more like this" 흐름의 대안 컬렉션
  3. 스레드 컨텐츠에서 부모 tweet을 seed로 두고 유사한 리플라이를 억제하는 시나리오

홈 타임라인의 For You 자체가 seed_tweet_id를 채워 주는지는 관측 대상이다. For You는 이론상 seed가 없는 슬레이트 생성이지만, 최근에 본 tweet을 seed로 실어 반복 노출을 줄이는 확장이 논리적으로 가능하다.

B. Phoenix 후보 점수 로그가 기존 Thrift 스트림과 새 proto 스트림으로 함께 나간다

홈믹서가 Phoenix 후보 점수를 Kafka로 보내는 통로가 하나 더 생겼다. 기존 통로는 Thrift 형식이고, 새 통로는 proto 형식으로 다른 토픽에 병렬로 발행한다. 다운스트림이 언제 proto로 넘어가는지는 이번 diff 밖이다.

  • 지금까지의 통로는 Thrift ScoredCandidate를 PHOENIX_SCORES_TOPIC(cluster Aiml)로 보내는 하나였다. 이 페이로드에는 28개 참여 헤드 점수와 후보 메타가 담긴다.

  • 이번 커밋에서 proto LoggedScoredCandidate를 LOGGED_SCORED_CANDIDATES_TOPIC(cluster Phoenix)로 병렬 발행하는 두 번째 통로가 붙었다.

  • 이제 후보 하나마다 두 페이로드를 만들어 두 Kafka publisher에 동시에 쓴다.

    • 파일은 home-mixer/side_effects/phoenix_experiments_side_effect.rs이고, 동시 쓰기에는 tokio::join!을 쓴다.
  • 첫 페이로드는 기존 Thrift ScoredCandidate다. 이 스트림은 4세대+3(11a71f8, 2026-08-18)의 v3 스키마 이래로 유지되고 있다.

    • 필드는 prediction_scores: Option<BTreeSet<Box<PredictionScore>>>처럼 정렬된 컬렉션이다.
    • Kafka 토픽은 PHOENIX_SCORES_TOPIC, cluster는 Aiml이다.
  • 두 번째 페이로드가 이번 커밋에서 새로 생긴 proto LoggedScoredCandidate다.

    // (proto 정의는 xai_proto::recsys_logging에 있음)
    message LoggedScoredCandidate {
        int64 post_id = ...;
        int64 viewer_id = ...;
        int64 author_id = ...;
        int64 request_join_id = ...;
        int64 request_time_ms = ...;
        int64 source_post_id = ...;
        bool  is_in_network = ...;
        int64 in_reply_to_post_id = ...;
        int64 quoted_post_id = ...;
        int64 quoted_user_id = ...;
        map<string, double> prediction_scores = ...;
        int64 fav_count = ...;
        int64 reply_count = ...;
        int64 retweet_count = ...;
        int64 quote_count = ...;
        bool  has_media = ...;
        int32 language_code = ...;
        int32 video_duration_ms = ...;
    }
    
  • 새 페이로드의 Kafka 토픽 이름은 새 상수 LOGGED_SCORED_CANDIDATES_TOPIC이다. cluster는 기존 Aiml과 별개인 새 KafkaCluster::Phoenix로 바꾼다.

    • PhoenixCandidatePipeline의 프로덕션 조립 코드가 여섯 번째 Kafka publisher slot을 이 토픽에 배정한다.

      async {
          Arc::new(
              ProdKafkaPublisherClient::new(
                  LOGGED_SCORED_CANDIDATES_TOPIC,
                  KafkaCluster::Phoenix,
              ).await,
          ) as Arc<dyn KafkaPublisherClient>
      },
      
  • 두 페이로드는 서로 다른 자료구조로 다시 정렬된다. 파이프라인은 내부에서 HashMap<String, f64>를 만들어 두고, Thrift 페이로드용으로만 이를 BTreeSet으로 정렬해 감싼다. proto 페이로드에는 HashMap을 그대로 map으로 싣는다.

    let mut prediction_scores = HashMap::new();
    for (cluster_id, result) in &experiment_results {
        ...
        insert_cluster_scores(&mut prediction_scores, &cluster_name, &scores);
    }
    
    let thrift_scores: BTreeSet<Box<PredictionScore>> = prediction_scores
        .iter()
        .map(|(name, value)| { Box::new(PredictionScore::new(...)) })
        .collect();
    
  • 키 명명 규약(phoenix.<cluster_name>.<action_name>)과 스코어 필드 집합은 그대로 유지된다. 필드 집합은 28개 참여 헤드와 pdwell 별칭이다.

  • 테스트도 새 헬퍼 insert_cluster_scores와 build_logged_scored_candidate에 각각 붙었다.

의미 prediction_scores의 map 표현은 두 축에서 이득이다. wire 크기에서는 정렬 인덱스가 사라지고, 소비 측 조회에서는 BTreeSet 대신 map으로 O(1) 조회를 한다. Thrift 스트림을 유지하는 것은 다운스트림 소비자가 즉시 바꿀 수 없기 때문이다 추정. 두 스트림을 병렬로 유지하는 이행기가 얼마나 이어질지는 이번 커밋에 없다.

C. VF가 TES와 Gizmoduck 서버를 찾는 방식을 xDS 경유로 옮겼다

visibility-filtering(VF) 서비스가 TES와 Gizmoduck에 붙는 방식이 바뀌었다. TES(Tweet Entity Service)는 X의 tweet 코어 조회 서비스이고, Gizmoduck은 user profile 서비스다. 붙는 경로는 datacenter 템플릿 hostname에서 xDS LDS listener 경유로 이관됐다. 서비스 발견 계층이 Envoy 계열 xDS로 옮겨진 것이다.

  • 이전에는 클라이언트 조립에 필요한 파라미터 뭉치가 서비스 crate 안에 정의된 구조체로 격리되어 있었다. 서비스 이름은 datacenter 문자열로 hostname 템플릿을 채워 만들었다.

    • 파일은 visibility-filtering/server_deps.rs다.

    • 구조체는 xai_core_entities::tweet_entity_service_client::TESClientConfig이고, hostname 템플릿을 채우는 값은 datacenter다.

      // 이전 (~fee1d0f)
      let tes_client = Arc::new(
          init_client_with_retry("tes", init_deadline, || {
              ProdTESClient::new_with_config(
                  None,
                  datacenter,
                  None,
                  tes_client_config(deterministic_aperture),
              )
          }).await
      );
      // tes_client_config는 TESClientConfig 구조체를 반환:
      // aperture_size, deterministic_aperture, lb_policy, readiness_probe_port,
      // request_timeout_ms, enable_serve_within 등 필드를 채운다.
      
  • 이번 커밋에서는 TES 클라이언트를 다음처럼 조립한다.

    // 신규 (6bb4594)
    let tes_client = Arc::new(
        init_client_with_retry("tes", init_deadline, || async move {
            let strato = build_xds_strato(
                XdsStratoParams {
                    name: "tes-xds",
                    xds_listener: "tweet-entity-service.prod.tweet-entity-service:fed-grpc",
                    tls_domain: format!(
                        "tweet-entity-service.tweet-entity-service.prod.{datacenter}.s2s.twttr.net"
                    ),
                    lb_policy: LbPolicy::penalized_peak_ewma(),
                    client_id: S2S_CLIENT_ID.clone(),
                    retry_config: Some(RetryConfig::for_idempotent()),
                    max_batch_size: TESRpcConstants::max_batch_size(),
                    enable_serve_within: true,
                },
                deterministic_aperture,
            ).await?;
            anyhow::Ok(ProdTESClient { grpc_client: Arc::new(strato) })
        }).await
    );
    
  • 새 헬퍼 build_xds_strato가 xai_x_rpc::grpc_client::ChannelBuilder로 gRPC 채널을 조립한다.

    async fn build_xds_strato(
        params: XdsStratoParams,
        deterministic_aperture: bool,
    ) -> anyhow::Result<StratoGrpc> {
        let mut builder = 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.to_string()))
            .await?
            .eager_resolution(XDS_EAGER_RESOLUTION_TIMEOUT)  // 5 s
            .aperture(STRATO_APERTURE_SIZE)              // 12
            .readiness_probe(READINESS_PROBE_PORT, "/ready")
            .buffer_max_wait(DEFAULT_BUFFER_MAX_WAIT)
            .total_timeout(DEFAULT_TOTAL_TIMEOUT);
        if deterministic_aperture { builder = builder.deterministic(); }
        let channel = builder.build_load_balanced(params.lb_policy).await?;
        let mut strato = StratoGrpc::from_load_balanced_channel(
            channel, None, Some(params.client_id),
            params.retry_config, params.max_batch_size,
        );
        if params.enable_serve_within {
            strato = strato.with_op_context(xai_strato::strato_proto::OpContext {
                serve_within: Some(xai_strato::strato_proto::ServeWithin {
                    duration_microseconds: STRATO_REQUEST_TIMEOUT.as_micros() as i64,
                    round_trip_allowance_microseconds: 10_000,
                }),
                ..Default::default()
            });
        }
        Ok(strato)
    }
    
  • 핵심은 .xds(StartFrom::Lds(params.xds_listener)) 한 줄이다. 클라이언트는 리스너 이름("tweet-entity-service.prod.tweet-entity-service:fed-grpc" 등)을 시작점으로 xDS control plane과 통신해 endpoint 리스트를 받는다.

    • xDS는 x Discovery Service 프로토콜이고, 이 코드의 시작점은 그 가운데 LDS(Listener Discovery Service)다.
    • 이는 Envoy 계열 서비스 메시에서 클라이언트가 컨트롤 플레인에서 대상 endpoint 리스트를 동적으로 받는 표준 방식이다.
  • TES 경로에만 붙는 옵션이 두 개 있다.

    • enable_serve_within: true로 서버 측에 데드라인 힌트를 실어 준다. 힌트는 요청이 서버 큐잉에서 얼마나 오래 대기해도 되는지를 알린다. duration_microseconds는 클라이언트 timeout과 같은 값(HYDRATION_TIMEOUT = 390 ms)이고, round_trip_allowance_microseconds = 10_000은 왕복 여유분 10 ms다.
    • retry_config: Some(RetryConfig::for_idempotent())로 idempotent 요청을 자동 재시도한다.
  • Gizmoduck 경로는 lb_policy가 least_request()이고, 위 두 옵션이 모두 꺼져 있다 확인 — server_deps.rs.

  • 이 이관으로 달라진 점은 네 가지다.

    1. 서비스 발견 계층이 hostname 템플릿에서 xDS control plane 경유로 옮겨졌다.
    2. 서비스 crate가 export하던 별도 client-config 구조체(TESClientConfig·GizmoduckClientConfig)가 사라지고, 클라이언트 조립 로직이 VF 서비스 자신의 server_deps.rs로 흡수됐다.
    3. L2 cache warmer의 Strato(코드가 데이터를 조회하거나 저장할 때 거치는 X 내부 시스템) 클라이언트도 이제 deterministic_aperture 플래그를 받는다.
    4. TES_READINESS_PROBE_PORT와 GIZMODUCK_READINESS_PROBE_PORT가 둘 다 8081이었으므로 통합 상수 READINESS_PROBE_PORT: u16 = 8081로 합쳐졌다.

4세대+19의 HYDRATION_TIMEOUT 통일이 여기까지 파급됐다는 관점으로도 읽힌다. 이번에는 클라이언트 조립 코드에서 timeout 상수가 STRATO_REQUEST_TIMEOUT: Duration = crate::hydration::HYDRATION_TIMEOUT로 참조된다.

D. Phoenix proto에 LLM 판정 기반 검색 관련성 액션과 새 logit 응답 포맷이 생겼다

Phoenix의 proto에 LLM이 판정한 검색 관련성을 나타내는 액션 두 개가 새로 생겼다. 요청한 액션의 logit을 sparse HashMap 대신 positional dense array로 돌려주는 응답 포맷도 함께 붙었다. 새 액션은 4세대+4(aad7179, 2026-08-19)에 등장한 xrecsys_search 검색 랭킹 config가 검색 관련성 예측을 LLM 판정으로도 supervise할 준비를 시작한 신호로 읽힌다.

  • 새 액션은 LLM_JUDGE_RELEVANT_TO_SEARCH/_NOT_RELEVANT_TO_SEARCH 한 쌍이고, 새 응답 포맷은 returnLogitsList다.

    • 번호는 LLM_JUDGE_RELEVANT_TO_SEARCH = 107과 LLM_JUDGE_NOT_RELEVANT_TO_SEARCH = 108이다.
  • recsys.proto 두 파일(Rust와 파이썬 미러) 양쪽에 필드 두 개와 enum 값 두 개가 붙었다.

  • 새 액션 두 개가 액션 enum에 들어갔다.

    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;
      ADS_WEB_CONVERSION = 128;
      ...
    }
    
  • 검색 관련성 축은 지금까지 client-side 시그널만 갖고 있었다. 사용자가 결과를 실제로 열었는지, 쿼리를 재작성했는지를 나타내는 시그널이다. 이번 커밋에서 LLM judge의 판정이 별도 액션 축으로 편입됐다.

  • 4세대+4(aad7179, 2026-08-19)에서 등장한 xrecsys_search는 별도 vocab과 condition_search_relevance_on_prompt=True 같은 검색 특유 설정을 갖고 있는 config인데, 이번 편입은 그 config가 훈련 target으로 삼는 액션 축에 LLM 판정을 supervise 신호로 넣을 준비로 읽힌다 추정.

  • Grox(Grok 기반 nearline 분류기 서브레포)나 전용 LLM judge가 (query, tweet) 쌍에 relevant/not 라벨을 흘려 주는 파이프라인이 어딘가에 있어야 한다. 하지만 이번 diff에는 그 파이프라인의 코드가 없다.

  • 새 응답 포맷 returnLogitsList의 proto 정의는 아래와 같고, 파이썬 미러 파일도 동일하다.

    message PredictNextActionsRequest {
      ...
      bool returnBackboneScores = 20;
      map<string, string> experiment_overrides = 21;
      bool returnLogitsList = 22;
    }
    
    message NextActionDistribution {
      ...
      repeated float backboneTopLogProbs = 10;
      repeated float backboneContinuousValues = 11;
      repeated float requestedActionLogits = 12;
    }
    
  • Rust 엔진 쪽에서는 PredictRequestItem에 return_logits_list: bool 필드가 추가됐다. PredictRequestBatch::forward 안에서 이 플래그가 켜지면 다음 경로로 응답한다.

    if item.return_logits_list {
        dist_entry.requested_action_logits = item
            .requested_action_indices
            .iter()
            .map(|&idx| {
                dist.get(idx as usize).copied().unwrap_or(f32::NEG_INFINITY)
            })
            .collect();
    } else if item.return_log_map {
        for idx in item.requested_action_indices.iter() {
            if let Some(v) = dist.get(*idx as usize) {
                dist_entry.index_to_logits.insert(*idx, *v);
            }
        }
    }
    
  • 두 형식은 상호배타적으로 우선순위가 잡혀 있다. 둘 다 켜면 return_logits_list가 이긴다.

  • continuous action index 처리는 이제 두 포맷이 공유한다. 이전에는 return_log_map 경로 안에만 있었다.

의미 새 포맷은 순서가 보장되고 index 저장이 없어지므로 wire 크기가 작다. 기존 return_log_map은 HashMap<u32, f32>(action_index → logit)를 wire에 실어 보내는 sparse map이다. 이 표현은 sparse할 때 좋지만, 요청한 액션 index가 촘촘히 정해진 세트일 때는 map key 오버헤드가 순수 손해다. returnLogitsList는 요청 index 순서대로 float array를 돌려주는 positional dense list이고, 요청 index가 모델 vocab을 벗어나면 -inf로 채운다. 아마 캐스케이드나 실시간 랭커처럼 액션 인덱스가 이미 알려진 소비자에 최적화된 것이다 추정.

E. 작성자 피처를 8 바이트에 담는 표현이 준비됐지만 아직 쓰는 곳은 없다

VF가 작성자 피처를 8 바이트에 담는 새 표현을 준비했지만, 이번 커밋에는 이 표현을 쓰는 코드가 없다. AuthorFeatures는 4세대+19에서 16 바이트로 축소됐고, 새 표현 CompactAuthorFeatures는 이를 8 바이트 NonZeroU64 하나에 담는다.

  • 4세대+19는 라벨 표현을 AuthorLabelSet(HashSet<LabelValue>) → AuthorLabelSet(u64)로 바꿔 64-bit bitset으로 줄이면서, AuthorFeatures 전체를 16 바이트 Copy 구조체로 고정했다.

  • 이번 커밋은 그것을 다시 8 바이트로 줄이는 표현을 준비했다.

    #[allow(dead_code)]
    pub(crate) struct CompactAuthorFeatures(NonZeroU64);
    
    const COMPACT_PRESENT_BIT: NonZeroU64 = match NonZeroU64::new(1 << 63) {
        Some(bit) => bit,
        None => unreachable!(),
    };
    const COMPACT_LABEL_SHIFT: u32 = 8;
    const COMPACT_LABEL_MASK: u64 = (1 << (63 - COMPACT_LABEL_SHIFT)) - 1;
    
  • 비트 레이아웃은 다음과 같다.

    비트 위치 내용
    63 present 마커 (항상 1)
    0~6 7개 계정 상태 flag (is_suspended, is_deactivated, is_protected, is_nsfw_user, is_nsfw_admin, is_erased, is_offboarded)
    7 미사용
    8~62 55-bit label bitset (실제 사용은 11 bit, 4세대+19의 AuthorLabel 11 variant와 1:1)
  • to_compact와 from_compact 라운드트립이 함께 정의됐다.

  • 테스트가 flag 조합 128개와 label 조합 2048개를 빠짐없이 모두 검증한다. 두 수는 각각 1 << 7과 1 << 11이고, 합치면 총 262,144 조합이다.

  • Option<CompactAuthorFeatures>의 크기도 정확히 8 바이트임을 assert한다. NonZeroU64의 niche 최적화가 None을 all-zero로 표현해 별도 태그가 필요 없다.

  • #[allow(dead_code)]가 붙어 있으니 이번 커밋에서 CompactAuthorFeatures의 소비자는 없다. 연결될 만한 곳으로는 다음 같은 후보가 있다.

    1. 4세대+19에 신설된 clock_cache.rs(32-way × 64-shard CLOCK 캐시)의 값 타입. 이 캐시는 V: Copy를 요구하고 8-byte 값에 최적화되어 있다 추정.
    2. Gizmoduck 하이드레이터의 hot-path 캐시
    3. AuthorFeatures의 wire 표현 자체
  • 다음 커밋에서 어느 소비자에 연결되는지 관측한다.

의미 VF 서비스가 실 지연을 낮추기 위한 두 방향 프로젝트를 병행 중이라는 신호로 읽힌다. 4세대+19의 HYDRATION_TIMEOUT 상향(80/100 → 390 ms)이 지연 예산을 늘리는 방향이었다면, 이 축은 캐시 hit rate와 hot-path 자료구조 크기를 줄이는 방향이다. timeout을 늘려 부분 판정 성공률을 높이고, 라벨과 피처 표현을 좁혀 캐시 밀도를 높인다.

F. 전 세계 범위 코드만 붙은 takedown 두 갈래가 더 이상 자동으로 드롭되지 않는다

VF의 worldwide takedown 규칙이 세밀해졌다. bystander 신고 가운데 worldwide 범위만 붙은 것과 legal-copyright 코드가 자동 드롭 대상에서 빠졌다. 다듬은 파일은 visibility-filtering/rules/context.rs이고, 다듬은 조각은 두 개다.

  • 첫째, world-wide copyright 코드로 표시된 legal request는 이제 DropLegalTakendownPostRule을 트리거하지 않는다.

    • legal_takedown_country가 이제 LegalRequest { country_code: "xy" }를 명시적으로 None으로 리턴한다.

      fn legal_takedown_country(reason: &TakedownReason) -> Option<&str> {
          match reason {
              TakedownReason::LegalRequest { country_code }
                  if country_code.eq_ignore_ascii_case(WORLDWIDE_COPYRIGHT_COUNTRY_CODE) =>
              {
                  None
              }
              TakedownReason::LegalRequest { country_code }
              | TakedownReason::UnspecifiedReason { country_code } => Some(country_code),
              TakedownReason::Dmca => Some(WORLDWIDE_COPYRIGHT_COUNTRY_CODE),
              _ => None,
          }
      }
      
  • DMCA는 여전히 "xy"를 리턴하므로 DMCA 기반 world-wide 저작권 takedown은 유지된다. 즉 "xy" 코드는 이제 DMCA reason으로 도착한 경우에만 legal takedown을 트리거하고, LegalRequest로 도착한 경우에는 무시된다.

  • 둘째, bystander 신고 중 world-wide 스코프만 붙은 것은 더 이상 DropLocalLawsTakendownPostRule을 트리거하지 않는다.

    • local_laws_takedown_country가 이제 BystanderReport { country_code: "xx" } 또는 "xy"를 None으로 리턴한다.

      fn local_laws_takedown_country(reason: &TakedownReason) -> Option<&str> {
          match reason {
              TakedownReason::BystanderReport { country_code } if !is_worldwide_code(country_code) => {
                  Some(country_code)
              }
              _ => None,
          }
      }
      
  • 국가 코드가 실제로 지정된 bystander 신고만 local laws rule이 소비한다.

  • 새 테스트 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)에서 국가 스코프 처리를 원본으로 도입한 이후 처음의 실질적 정책 완화다. UnspecifiedReason은 worldwide여도 여전히 드롭이 유지되므로, 완화는 두 갈래에만 적용된다.

  • LegalRequest{"xy"}는 이전에 드롭됐고, 이제 허용된다. "xy"는 저작권 코드로 예약되어 있으므로, legal 채널로 저작권 관련 요청이 도착해도 실제 판정 근거는 DMCA reason이 별개로 도착할 때에만 성립하도록 정리한 것이다.
  • BystanderReport{"xx"} 또는 BystanderReport{"xy"}는 이전에 드롭됐고, 이제 허용된다. bystander 신고는 국가 코드가 명확해야만 rule에 걸린다.

이 두 완화는 이전 규칙이 world-wide 스코프에 대해 지나치게 넓었다는 인식이 있었음을 시사한다. 실제로 이 조건들이 프로덕션에서 얼마나 자주 발생했는지, 어떤 크리에이터가 영향을 받았는지는 이 코드만으로는 알 수 없다.

G. Under-The-Hood 리포트에 월 상한 파라미터와 employee 역할이 생겼다

Under-The-Hood(UTH, X의 라벨 투명성 툴) 리포트가 보여 주는 월에 운영자가 상한을 걸 수 있게 됐다. 리포트를 볼 수 있는 역할에는 employee가 추가됐다.

  • under-the-hood/strato/lib/params.strato가 새 int 파라미터를 export한다.

    val underTheHoodReportMaxMonthBucket =
      fs.intParam("under_the_hood_report_max_month_bucket", 0)
    
  • userRoles에는 "employee"가 추가된다.

    val userRoles = Set(
      "birdwatch_team",
      "employee",
    )
    
  • underTheHoodReport.User.strato의 reportMonthBucket 시그니처가 확장돼 이 상한을 적용한다. 기본값 0이면 이전과 같이 최근 완성된 calendar 월을 리턴하고, 양수를 넣으면 그 값을 상한으로 클램프한다.

    def reportMonthBucket(now: Time.T, maxMonthBucket: Int): Int = {
      val candidate = calendarMonthBefore(now)
      val calendarMonth =
        if (monthPublishCalendarReady(candidate, now)) { candidate }
        else { monthBucketBefore(candidate) }
      if (maxMonthBucket > 0 && calendarMonth > maxMonthBucket) {
        maxMonthBucket
      } else {
        calendarMonth
      }
    }
    

의미 이 변경으로 두 가지가 달라졌다.

  1. 운영자가 안전판을 손에 쥐게 됐다. 데이터 파이프라인이 특정 월의 백엔드 준비를 완료하지 못한 상황(예: 인시던트, 지연, 롤아웃 중단)에서 UTH 리포트를 안전한 마지막 월에 강제로 고정할 수 있다.
  2. "employee" 역할이 리포트를 볼 수 있는 대상으로 편입된다. 사내에서 리포트 UI를 검증·디버깅할 수 있게 됐다는 의미로 읽힌다.

이 안전판은 4세대+19에서 신설된 UTH 국가별 계정 takedown 배치 잡과 짝을 이룬다.

H. Phoenix 학습 파이프라인이 Kafka 인증 방식을 자동으로 골라 mTLS로도 붙을 수 있게 됐다

Phoenix 학습 파이프라인이 Kafka에 붙을 때 쓸 인증 방식을 환경을 보고 자동으로 고르게 됐다. 이제 SASL뿐 아니라 mTLS로도 붙을 수 있다.

  • phoenix/xrex/data/streaming/kafkaconsumer.py에 인증 설정을 담는 새 dataclass가 붙었다.

    @dataclasses.dataclass(frozen=True)
    class KafkaAuth:
        mode: str                       # "mtls" or "sasl"
        ca_path: str | None = None
        cert_path: str | None = None
        key_path: str | None = None
        sasl_mechanism: str | None = None
        sasl_username: str | None = None
        sasl_password: str | None = None
        verify_broker: bool = False
    
        @classmethod
        def mtls(cls, ca_path, cert_path, key_path, verify_broker=False): ...
        @classmethod
        def sasl(cls, mechanism, username, password): ...
    
        def ssl_context(self) -> ssl.SSLContext: ...
        def consumer_kwargs(self) -> dict[str, Any]: ...
    
  • kafkaloader.py의 auto_detect_auth가 다음 순서로 인증 방식을 정한다.

    1. KAFKA_AUTH env var가 "sasl"이면 SASL을 강제한다. 이때 mTLS 인증서 검사는 건너뛴다.
    2. KAFKA_MTLS_CERTS_DIR(기본 /root/client-certs)에 세 파일이 모두 있으면 mTLS를 쓴다. 세 파일은 ca-bundle.crt, client.fullchain, client.key다.
    3. client 인증서와 key만 있고 CA bundle이 없으면, 다른 truststore를 삼아 mTLS를 쓴다. 이때 truststore는 KAFKA_MTLS_PLATFORM_CA_BUNDLE(기본 /etc/ssl/internal-ca/ca-bundle.crt)이나 시스템 truststore다. 시스템 truststore는 ssl.get_default_verify_paths().cafile이다.
    4. SASL_PLAIN_PASSWORD가 있으면 SASL/SCRAM으로 fallback한다.
    5. 셋 다 실패하면 에러를 낸다.
  • 이전 코드는 소비자 경로 3개에서 각각 ssl.create_default_context()와 SASL_SSL protocol을 하드코딩했다. 이번 커밋은 세 경로를 모두 새 auth.consumer_kwargs()로 옮겼다.

    • 세 경로는 discover_partition_count, _consumer_poll_loop, _consume_multi_consumer다.

의미 Phoenix 학습 파이프라인이 소비하는 recsys 액션 스트림 Kafka가 mTLS로도 붙을 수 있게 됐다. 이 Kafka는 4세대+3의 v3 스키마 이래로 SASL/SCRAM으로 붙어 왔다. 클러스터 운영 정책이 비밀번호 관리 인프라에서 인증서 관리 인프라로 옮겨 가는 것으로 읽힌다 추정. 실제로 어느 쪽이 프로덕션에서 채택됐는지는 이 코드만으로는 알 수 없다.

I. 그 밖의 변경

나머지 네 변경은 정책 변화가 없는 정비다.

  • 브라질 선거 필터의 하드코딩 계정 목록을 정비했다. 정기 유지 보수이고, 정책 변화는 없다.
    • 필터는 Brazil2026ElectionFilter, 파일은 home-mixer/filters/brazil_2026_election_filter.rs(+334/−42)다.
    • 목록이 2,554개에서 2,707개로 순증 153개 늘었다 확인 — brazil_2026_election_filter.rs의 테스트 assert. patch는 // @handle 항목 163개를 더하고 10개를 뺐다.
    • 삽입한 계정에는 @VladimirAlves, @merisio, @LorenLuniere, @capitaoassumcao, @annavalente, @ademarmago, @henriquecatan, @jabscres, @yarahaquim, @ElySilveira, @MarceloFaro30, @freire_roberto, @felipececilio, @claudiojacoski 등이 있다.
    • @rafaelcampelo → @RobertoOshiro로 계정을 바꿨다.
    • "no live account found" 코멘트를 여럿 추가하고 삭제했다.
  • VF 클라이언트 두 파일을 위 C의 xDS 이관에 맞춰 조금 고쳤다. 실질 정책 변화는 없다.
    • 고친 파일은 visibility-filtering-client/discovery.rs(+11/−8)와 vf_client.rs(+1/−1)다.
  • home-mixer의 feature-switch 기본값 미러 파일에서 동기화 시각만 바뀌었다. 파라미터 값 변경은 없다.
    • home-mixer/params/param.rs의 미러 timestamp가 2026-09-10T16:21:03Z → 2026-09-11T16:22:45Z로 갱신됐다.
  • Grox의 motion-reveal 이미지 빌더를 조금 정비했다. 4세대+19에 도입된 ECC·Farneback 정합 파이프라인의 후속 정리이고, 정책 변화는 없다.
    • 고친 파일은 grox/libs/video_tools/image.py(+60/−55)다.

정본 반영 services/x.md에 ### 4세대+20 (2026-09-12, 6bb4594) 절을 새로 만들어 A~I 아홉 축을 적었다. ### 다양성 절에는 VMRanker DPP의 seed_tweet_id 확장 노트를 추가했다. ## 출처 절의 4세대 커밋 리스트에는 6bb4594를 21개째로 넣었고, 전체 커밋 카운트를 19개 → 21개로 고쳤다(4세대+19도 이번에 함께 추가). 최종 갱신 날짜는 2026-09-12 → 2026-09-13으로 올렸다.

의미 이번 커밋은 4세대+19에서 시작된 VF 안정화·감축 프로젝트의 두 번째 이터레이션이고, 검색 관련성 supervise 라벨(LLM_JUDGE_*)이 proto에 등장한 첫 신호다. 다양성 축에서는 seed anchor 도입이 실질적 신기술이다. 지금까지 노트에서 "DPP는 집합 내부 다양성만 최적화"라고 기술했던 것을 "seed가 주어지면 참조 tweet 대비 다양성도 계산"으로 확장해야 하고, 이 확장은 services/x.md의 ### 다양성 노트에 반영했다.

  • 로그 스트림 축에서는 Thrift→proto 이관이 시작됐다.
  • 인프라 축에서는 xDS로의 서비스 발견 이관이 VF에서 시작됐다.
  • 정책 축에서는 world-wide 스코프 takedown이 두 갈래에서 완화됐다.

⚠ 주장 갱신 없음: 이번 diff가 뒤집는 기존 정본 진술은 없다. 다양성 절의 진술은 확장됐지만 뒤집히지는 않았다. seed가 없는 경우의 동작은 그대로다.

검증에서 뒤집힌 것

뒤집힌 것은 없다.

레이더

이번 배치에서 triage한 arXiv cs.IR 30건 가운데 acceptance 기준을 통과한 항목은 없다. 기준은 플랫폼 기업 저자, 프로덕션 A/B, 메커니즘 신기술, 대규모 스케일 결과다. Meta 엔지니어링 블로그(engineering.fb.com, ai.meta.com/blog)에도 2026-09-12 이후 신규 자료가 없다. Netflix TechBlog(netflixtechblog.com)도 마찬가지다.

다음에 볼 것