0x00

리서치 / 일일 리포트

  • 읽은 자료 1건
  • 정본 반영 1개 파일
  • 레이더 1건
한 줄 요약X 안전 필터(visibility-filtering)가 백엔드 조회 timeout을 390 ms로 늘리고, tweet 조회가 실패해도 캐시된 저자 id로 저자 기준 규칙을 판정하게 했다.

1안전 필터가 백엔드를 더 오래 기다리고, 조회 실패에 대비한 저자 캐시를 갖췄다 — X

자료 xai-org/x-algorithm@fee1d0f — 공개 소스코드, 2026-09-10, 파일 33개, +1587/−391. 이 노트들은 4세대 공개 뒤의 X 커밋에 차례로 번호를 붙여 부르며, 이 커밋(2026-09-10, fee1d0f)은 4세대+19다.

배경 X는 2026-08-13 4세대 공개 이래 For You 파이프라인 소스코드를 xai-org/x-algorithm에 매일 두 번 정도 미러링한다. visibility-filtering(VF, 코드는 visibility-filtering/)은 X 홈 타임라인의 안전 필터 gRPC 서비스이고, Rust로 재작성된 3세대 스택이다. 홈믹서(For You 피드를 조립하는 서비스)가 후보 리스트를 만들면, VF는 후보마다 tweet 메타데이터와 저자 라벨을 두 백엔드에서 가져와 채운 뒤 규칙 엔진에 넣는다. 이번 커밋은 VF에 집중한 중형 diff로, 백엔드 조회의 시간 예산과 실패 처리를 고치고 저자 라벨 자료구조를 줄였다.

VF가 백엔드에서 데이터를 가져와 채우는 이 단계를 하이드레이션이라 한다. tweet 메타데이터는 TES(X의 tweet 코어 조회 서비스인 Tweet Entity Service)에서 가져온다. 저자 라벨은 Gizmoduck(user profile 서비스)에서 가져온다. 하이드레이션에는 두 백엔드를 얼마나 기다려 줄지, 실패했을 때 판정을 어디까지 진행할지, 지연이 어디서 소진되는지라는 세 결정이 얽혀 있다. 이번 커밋은 셋을 모두 건드렸다.

변경은 크게 두 축이다.

  • 축 1은 하이드레이션의 시간 예산과 실패 처리다. 기다리는 시간을 늘렸고, 실패해도 캐시로 저자 기준 규칙을 판정할 길을 열었으며, 지연이 어디서 소진되는지 기록하기 시작했다.
  • 축 2는 저자 라벨 감축과 캐시 primitive 준비다. 라벨은 규칙이 쓰는 11종만 남았고, 새 캐시는 아직 쓰이지 않는다.
  • 축 2를 벗어나는 부수 변경도 있다. 추천 모델의 proto 상수와 모델 코드, 광고 로그 필드, 라벨 투명성 툴의 배치 잡, 이미지 빌더가 바뀌었다.

새로 알게 된 것A. 두 백엔드를 기다리는 시간을 390 ms 상수 하나로 통일하며 크게 늘렸다

  • VF의 hydration timeout이 통합 상수 하나로 바뀌었고, Strato(코드가 데이터를 조회하거나 저장할 때 거치는 X 내부 시스템) 호출의 timeout은 TES 100 ms, Gizmoduck 80 ms에서 390 ms로 올랐다 확인 — visibility-filtering/hydration/mod.rs

  • 이 상수는 두 층에서 쓰인다. 첫째는 hydrator 층이다. TES·Gizmoduck hydrator의 클라이언트 timeout은 4세대 공개 이래 각각 로컬 상수 150 ms였는데, 이번에 둘 다 이 상수를 참조하게 됐다 확인(같은 커밋).

    • 두 hydrator의 CLIENT_TIMEOUT이 이전에는 각각 로컬 상수 150 ms였고, 이제는 둘 다 crate::hydration::HYDRATION_TIMEOUT을 참조한다.
  • 둘째는 Strato 클라이언트 층이다. 서비스별로 하드코딩돼 있던 timeout 상수가 둘 다 이 상수 값으로 바뀌었다 확인(같은 커밋).

    • 이전: TES 100 ms(TES_STRATO_REQUEST_TIMEOUT_MS: u64 = 100), Gizmoduck 80 ms(GIZMODUCK_STRATO_REQUEST_TIMEOUT_MS: u64 = 80)
    • 이후: 둘 다 HYDRATION_TIMEOUT.as_millis() as u64
  • 즉 Strato 호출자가 실제로 기다리는 timeout은 두 서비스에서 각각 3.9배, 4.9배 늘었다 확인(같은 커밋). VF 서비스가 하이드레이션에 훨씬 더 넉넉한 지연 예산을 쓰겠다는 결정이다.

  • 4세대+7(reference_compare.rs, 2026-08-24)이 도입한 verdict 대조 canary에서 timeout으로 인한 disagreement가 관측됐을 가능성이 있고 추정, 이번 상향은 그 대응으로 읽힌다.

새로 알게 된 것B. TES 조회가 실패해도 캐시에 남은 저자 id로 저자 기준 규칙을 판정한다

  • TES 조회에 실패한 tweet도 이제 캐시에 기록해 둔 이전 저자 id로 저자를 찾을 수 있으며, 이 폴백은 기본 활성이고 기본 용량은 1 M entries다 확인 — visibility-filtering/hydration/tes_hydrator.rs (+234/−42)

  • 이전에는 TES에서 tweet을 찾지 못했거나 조회가 실패했을 때 요청에도 저자 id가 없으면, 그 후보는 판정 자체를 건너뛰었다 확인(같은 커밋).

    • TES가 tweet 하나를 조회한 결과는 세 갈래다: Found(core_data), NotFound, Failed(error).
    • 이전 코드에서는 결과가 Found가 아니면 그 tweet의 PureCoreData가 없었다. 이때 resolve_candidate가 raw.request_author_id(홈믹서가 요청에 실어 준 저자 id, 없을 수도 있다)를 찾지 못하면, 그 후보는 candidate 리스트에서 아예 빠졌다.
  • 이번 커밋은 이 경우를 캐시 폴백으로 보강했다. TesHydrator에 저자 id 폴백 캐시 필드가 붙었다 확인(같은 커밋).

    • 새 필드 정의:

      pub(crate) type AuthorIdFallbackCache = FallbackCache<TweetId, u64>;
      
      pub struct TesHydrator {
          pub tes_client: Arc<dyn TESClient + Send + Sync>,
          author_id_cache: Option<AuthorIdFallbackCache>,
      }
      
  • fetch_pure_core는 이제 HashMap<TweetId, PureCoreData> 대신, core 데이터와 캐시로 되살린 저자 id를 함께 담은 구조체를 돌려준다 확인(같은 커밋).

    • 새 반환 타입:

      pub(crate) struct PureCoreHydration {
          pub(crate) core: HashMap<TweetId, PureCoreData>,
          pub(crate) recovered_authors: HashMap<TweetId, u64>,
      }
      
  • 조회 결과가 Found면 (tweet_id, author_id)를 캐시에 기록하고 core도 채운다. NotFound·Failed면 캐시에서 이 tweet의 이전 author_id를 조회한다 확인(같은 커밋).

  • resolve_candidate가 저자 id를 고르는 우선순위는 요청, TES core, 캐시 순이다 확인(같은 커밋).

    • 요청이 저자 id를 직접 실어 주면 그것을 쓰고, 아니면 TES core가 있으면 거기서 뽑고, 그것도 없으면 캐시 폴백을 쓴다. 세 경로 모두에서 저자 id를 얻지 못하면 그 후보는 판정을 건너뛴다.

    • 코드:

      let author = raw
          .request_author_id
          .or_else(|| core.get(&raw.tweet_id).map(|c| c.author_id))
          .or_else(|| recovered_authors.get(&raw.tweet_id).copied())?;
      
  • 그 결과, TES가 죽었거나 응답이 없어도 이 tweet의 이전 author_id가 캐시에 있으면 author-side rules는 여전히 판정된다 확인(같은 커밋).

    • author-side rules는 유저의 계정 상태(suspended/deactivated/protected/erased/offboarded)와 저자 user_labels 11종이다. 11종은 아래 C에서 다룬다.
  • tweet-side rules는 TES core 없이는 판정할 수 없으므로 여전히 건너뛴다 확인(같은 커밋).

    • tweet-side rules는 nullcast, community-only, NSFW media, takedown reason, exclusive content, edit control이다.
  • 폴백 설정은 새 환경변수 두 개로 노출되며, 환경변수가 세팅되지 않았을 때는 활성이 기본이다 확인(같은 커밋). 프로덕션 배포에서는 opt-out으로만 끌 수 있고, 배포 순간부터 켜져 있다.

    • is_none_or를 써서, 환경변수가 없으면 활성이고, 값이 있으면 그 값을 파싱한다.

    • 두 환경변수를 읽는 코드:

      pub(crate) fn author_id_fallback_enabled() -> bool {
          std::env::var("VF_AUTHOR_ID_FALLBACK_ENABLED")
              .ok()
              .is_none_or(|value| parse_env_flag(Some(&value)))
      }
      
      pub(crate) fn author_id_fallback_capacity() -> usize {
          std::env::var("VF_AUTHOR_ID_FALLBACK_CAPACITY")
              .ok()
              .and_then(|value| value.trim().parse::<usize>().ok())
              .filter(|capacity| *capacity > 0)
              .unwrap_or(1_000_000)
      }
      
  • server_deps.rs는 초기화할 때 이 두 설정값을 info! 필드로 남긴다 확인(같은 커밋). 이 로그는 부팅 로그에 지표별로 잡혀 있어서, 실제 프로덕션 값이 다르면 즉시 감지된다.

    • 초기화 로그:

      info!(
          hydrator_count = 5,
          fallback_cache_enabled,
          author_id_fallback_enabled,
          author_id_fallback_capacity,
          home_rule_count,
          recommendations_rule_count,
          "VFServer initialized with prod clients"
      );
      

A와 B는 사실상 한 프로젝트다. A가 timeout을 2.6~4.9배 늘려 하이드레이션이 더 오래 걸리는 것을 허용하는 대신, B는 그래도 실패할 경우의 자동 폴백 경로를 만들었다.

이 폴백은 4세대+9(2026-08-26)에서 관측한 "TesHydrator media fallback cache 통째 제거"를 뒤집는 방향이 아니다. media fallback은 TES가 반환한 media 정보를 캐싱해 재사용하는 것이었고, 이번 author-id fallback은 TES가 응답을 못 준 tweet에 대해서만 이전 값을 되살려 author-side rules만 부분 판정한다. 즉 결과의 재사용이 아니라 부분 판정의 부활이다.

이번 변경은 4세대+15(2026-09-04)의 VF hydration 대규모 단순화로 코드가 800+ 줄 순 감소해 얇아진 뒤, 남은 강건성의 빈틈을 좁힌 것으로 읽힌다.

새로 알게 된 것C. 저자 라벨을 규칙이 실제로 쓰는 11종만 담는 자료구조로 바꿨다

  • VF에서 저자 라벨을 담는 자료구조가 해시 집합에서 11개 라벨만 표현하는 enum과 64-bit bitset으로 바뀌었다 확인 — visibility-filtering/models/author.rs (+49/−13)

  • 규칙에서 실제로 참조하는 라벨은 이 11개뿐이므로 이 축소는 맞다. 다만 thrift에서 오는 다른 라벨은 이제 입력 단계에서 명시적으로 버려지고 카운터에만 잡힌다.

  • models/author.rs는 전면 재작성됐다 확인(같은 커밋).

    • 이전 정의:

      use std::collections::HashSet;
      use xai_x_thrift::user_labels::LabelValue;
      
      #[derive(Clone, Debug, Default)]
      pub struct AuthorFeatures {
          pub is_suspended: bool,
          // ...
          pub user_labels: UserLabelSet,
      }
      
      #[derive(Clone, Debug, Default)]
      pub struct UserLabelSet {
          labels: HashSet<LabelValue>,
      }
      
    • 새 정의:

      #[derive(Clone, Copy, Debug, Default)]
      pub struct AuthorFeatures {
          pub is_suspended: bool,
          // ...
          pub user_labels: AuthorLabelSet,
      }
      
      #[derive(Clone, Copy, Debug, PartialEq, Eq)]
      #[repr(u8)]
      pub enum AuthorLabel {
          NsfwHighRecall,
          NsfwHighPrecision,
          NsfwNearPerfect,
          NsfwAvatarImage,
          NsfwBannerImage,
          SpamHighRecall,
          AbusiveHighRecall,
          Compromised,
          ReadOnly,
          ImpersonationHighPrecision,
          DoNotAmplify,
      }
      
      #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
      pub struct AuthorLabelSet(u64);
      
  • AuthorLabelSet(u64)는 11 variant를 각 비트에 매핑한 정수 하나이고, Insert·has는 shift와 AND 연산으로 처리한다 확인(같은 커밋).

  • 새 assert 두 개가 크기를 고정한다. AuthorFeatures는 정확히 16 바이트이고, Option<AuthorFeatures>도 16 바이트다 확인(같은 커밋).

    • bool 필드 하나가 Option niche로 활용된다.

    • 크기를 고정하는 assert:

      assert_copy::<AuthorFeatures>();
      assert_eq!(std::mem::size_of::<AuthorFeatures>(), 16);
      assert_eq!(std::mem::size_of::<Option<AuthorFeatures>>(), 16);
      
    • 이전 HashSet<LabelValue> 시절에는 heap 할당과 hash bucket을 매번 잡아야 했다.

  • Gizmoduck 하이드레이터의 라벨 매핑은 명시적 함수 하나로 줄었다 확인(같은 커밋). thrift에서 오는 LabelValue(20+ 종의 enum) 각각이 이 매핑을 거쳐 Option<AuthorLabel>로 좁혀지고, None이면 카운트만 올라간다.

    • 매핑 함수:

      fn author_label(value: LabelValue) -> Option<AuthorLabel> {
          match value {
              LabelValue::NSFW_HIGH_RECALL => Some(AuthorLabel::NsfwHighRecall),
              LabelValue::NSFW_HIGH_PRECISION => Some(AuthorLabel::NsfwHighPrecision),
              LabelValue::NSFW_NEAR_PERFECT => Some(AuthorLabel::NsfwNearPerfect),
              LabelValue::NSFW_AVATAR_IMAGE => Some(AuthorLabel::NsfwAvatarImage),
              LabelValue::NSFW_BANNER_IMAGE => Some(AuthorLabel::NsfwBannerImage),
              LabelValue::SPAM_HIGH_RECALL => Some(AuthorLabel::SpamHighRecall),
              LabelValue::ABUSIVE_HIGH_RECALL => Some(AuthorLabel::AbusiveHighRecall),
              LabelValue::COMPROMISED => Some(AuthorLabel::Compromised),
              LabelValue::READ_ONLY => Some(AuthorLabel::ReadOnly),
              LabelValue::IMPERSONATION_HIGH_PRECISION => Some(AuthorLabel::ImpersonationHighPrecision),
              LabelValue::DO_NOT_AMPLIFY => Some(AuthorLabel::DoNotAmplify),
              _ => None,
          }
      }
      
  • 새 메트릭 vf_author_labels{result="mapped"|"unmapped"}가 생겼고, 계약이 어긋나지 않게 컴파일 시 강제하는 테스트도 있다 확인(같은 커밋).

    • 테스트:

      #[test]
      fn dashboard_generator_pins_the_author_labels_metric_name() {
          // ... asserts dashboard.py contains AUTHOR_LABELS_METRIC = "vf_author_labels"
      }
      
  • 이 11개는 4세대+13(7ba7768, 2026-09-01)에서 정본에 반영된 OON_USER_LABEL_DROPS 규칙 11개와 정확히 1:1이다 확인(같은 커밋). 즉 규칙에서 실제로 참조되는 라벨만 남기고 나머지는 입력에서 제거한 것이다.

⚠ 정본 내용을 다시 확인했다. services/x.md의 안전 파이프라인 라벨 목록은 이전에도 이 11개를 담고 있었다. 이번 커밋이 한 일은 그 사실을 자료구조로 고정한 것이지, 새 라벨을 추가한 것이 아니다. 다만 thrift에 정의된 다른 LabelValue(예: NSFW_TEXT_HIGH_PRECISION, NSFW_TEXT_HIGH_RECALL, NSFW_NEW_USER 등)가 hydrator 층에서 명시적으로 버려진다는 사실은 이 커밋으로 처음 확인된다.

새로 알게 된 것D. VF가 호출자의 시간 예산 안에 응답했는지를 처음으로 기록한다

  • VF가 요청에 실려 온 gRPC 데드라인 안에 응답했는지를 메트릭으로 남기고, 요청 시간을 두 phase로 나눠 기록한다 확인 — visibility-filtering/filter_tweets.rs (+42)

  • filter_tweets.rs::FilterTweetsEndpoint::filter_tweets가 요청 metadata의 gRPC 표준 grpc-timeout 헤더를 파싱한다 확인(같은 커밋).

    • gRPC 스펙의 단위 접미사 H/M/S/m/u/n(시·분·초·밀리·마이크로·나노)을 지원한다. 숫자는 8자리 이하여야 하고, 그 밖의 형식은 None이다.

    • 파싱 함수:

      fn parse_grpc_timeout(metadata: &MetadataMap) -> Option<Duration> {
          let raw = metadata.get("grpc-timeout")?.to_str().ok()?;
          let (digits, unit) = raw.split_at(raw.len().checked_sub(1)?);
          if digits.is_empty() || digits.len() > 8 || !digits.bytes().all(|b| b.is_ascii_digit()) {
              return None;
          }
          let value: u64 = digits.parse().ok()?;
          let nanos_per_unit = match unit {
              "H" => 3_600_000_000_000,
              "M" => 60_000_000_000,
              "S" => 1_000_000_000,
              "m" => 1_000_000,
              "u" => 1_000,
              "n" => 1,
              _ => return None,
          };
          Some(Duration::from_nanos(value.checked_mul(nanos_per_unit)?))
      }
      
  • 성공 응답 시점에 RequestMetricsGuard::record_deadline(grpc_timeout)이 호출된다 확인(같은 커밋). 데드라인 안에 응답했는지, 넘겼는지, 데드라인이 없었는지를 카운터로 세고, 남은 시간과 초과 시간을 히스토그램으로 남긴다.

    • 기록 함수:

      pub(crate) fn record_deadline(&self, grpc_timeout: Option<Duration>) {
          let Some(deadline) = grpc_timeout else {
              incr(DEADLINE, &[("outcome", "absent")], 1);
              return;
          };
          let elapsed = self.start.elapsed();
          if elapsed <= deadline {
              incr(DEADLINE, &[("outcome", "within")], 1);
              observe_vm(DEADLINE_REMAINING_MS, &[], millis(deadline - elapsed));
          } else {
              incr(DEADLINE, &[("outcome", "overrun")], 1);
              observe_vm(DEADLINE_OVERRUN_MS, &[], millis(elapsed - deadline));
          }
      }
      
    • 세 카운터는 filter_tweets_deadline{outcome=within|overrun|absent}이고, 두 히스토그램은 filter_tweets_deadline_remaining_ms / filter_tweets_deadline_overrun_ms다.

  • VF는 호출자(홈믹서)가 준 데드라인 대비 자기 지연이 얼마나 여유가 있었는지, 초과했는지, 측정할 수 없었는지를 처음으로 관측한다 확인(같은 커밋).

  • filter.rs::FilterTweets::run은 요청 시간을 두 phase로 나눠 잰다 확인(같은 커밋). 새 히스토그램 filter_tweets_phase_ms{stage="hydration"|"post_hydration"}로, 요청 총지연이 하이드레이션과 rule engine에 어떻게 배분되는지도 처음 관측할 수 있다.

    • 두 phase를 재는 코드:

      let started = Instant::now();
      let hydration = self.hydration_pipeline.hydrate(...).await;
      let hydrated_at = Instant::now();
      ft_metrics::record_phase("hydration", hydrated_at - started);
      // ... rule engine
      ft_metrics::record_phase("post_hydration", hydrated_at.elapsed());
      

A의 timeout 상향과 이 관측 추가는 함께 배포되는 것이 자연스럽다. timeout을 늘렸으면 실제로 얼마나 늘었는지 봐야 하고, 그 지연이 어느 phase에서 소진되는지 알아야 한다.

또 홈믹서가 VF를 호출할 때 요청에 gRPC 표준 grpc-timeout 헤더를 실어 주는 지금까지의 관례가 이번에 처음 코드에서 확인된다. 이전 커밋에서는 timeout이 caller-side에서만 시행됐고, VF 서비스는 그 값을 읽지 않았다.

새로 알게 된 것E. 샤드로 나눈 새 캐시 primitive가 들어왔지만 아직 쓰는 곳이 없다

  • VF에 32-way set-associative × 64-shard CLOCK 캐시 primitive가 새로 들어왔지만, 아직 어디에도 연결되지 않았다 확인 — visibility-filtering/clock_cache.rs

    • 함께 바뀐 파일: visibility-filtering/lib.rs
    • visibility-filtering/clock_cache.rs는 신규 파일 446줄이고, visibility-filtering/lib.rs에 pub(crate) mod clock_cache; 한 줄로 등록됐다. 이번 커밋에서 이 파일을 참조하는 코드는 없다.
  • 신규 파일 clock_cache.rs(446줄)로 들어온 32-way × 64-shard 캐시에는 dead_code 애트리뷰트가 붙어, 컴파일러 경고를 눌러 두었다 확인(같은 커밋). 이것으로 보아 아직 소비자가 없고, 다음 커밋에서 어느 hydrator가 clock_cache.rs의 캐시를 소비하는지가 관측 대상이다.

    • 파일 첫머리:

      #![cfg_attr(not(test), expect(dead_code, reason = "TODO: no consumer wired yet"))]
      
    • 컴파일러에게 "테스트가 아닌 빌드에서는 이 모듈이 죽어 있는 것을 알고 있다, 경고 내지 마라, 사유는 아직 소비자가 붙지 않았기 때문이다"라고 명시한 것이다.

  • 용량 capacity를 인자로 받아 set_count = capacity / WAYS개 set을 만들고 64개 shard로 분산한다 확인(같은 커밋). shard 하나가 여러 set을 가지고, set 하나가 32개 slot(way)을 가진다.

    • Slot에 저장하는 값은 (key: u64, generation: u64, value: Option<V>) 튜플이다.

    • 주요 정의:

      const WAYS: usize = 32;
      const SHARDS: usize = 64;
      
      #[derive(Clone, Copy, Debug, PartialEq, Eq)]
      pub(crate) enum Lookup<V> {
          Miss,      // 키가 이 캐시에 등록조차 안 됨
          NotFound,  // 등록됐지만 값이 None(예: 백엔드가 NotFound를 리턴)
          Found(V),
      }
      
      pub(crate) struct ClockCache<V> { /* shards, set_count, next_generation */ }
      
  • CLOCK 알고리즘은 다음처럼 돈다 확인(같은 커밋).

    • 삽입(insert(generation, key, value)): 같은 키가 이미 있고 저장된 generation이 더 크면 건너뛴다. 오래된 값이 새 값을 덮어쓰지 못하게 하려는 것이다. 그렇지 않으면 그 자리에 덮어쓰고 Mark::Referenced로 표시한다. 키가 없으면 hand가 회전하면서 Referenced → Cold로 감쇠시키고, Cold나 Empty를 만나면 그 자리에 삽입한다.
    • 조회(get(key)): 같은 키를 찾으면 Mark::Referenced로 승격하고 Lookup::Found(value)를 리턴한다. 없으면 Miss이고, 저장된 값이 None이면 NotFound다.
    • Generation: 요청마다 begin_request() -> u64가 monotonic 값을 부여한다. 나중에 오는 요청이 오래된 캐시 값을 덮어쓸 수 있는지를 이 값으로 판정한다.
  • 이 캐시는 기존 FallbackCache와는 별개의 primitive다 확인(같은 커밋).

    • 기존 FallbackCache는 4세대에서 정본에 반영된 hydration/fallback_cache.rs이고, 뮤텍스 하나로 보호되는 moka::sync::Cache 기반 TinyLFU다.
  • 두 캐시는 잠금 단위, 교체 정책, 슬롯 배치, 값 타입, 동시성에서 다르다 확인(같은 커밋).

축 기존 FallbackCache (moka와 Mutex) 새 ClockCache
잠금 단위 캐시 전체에 Mutex 하나 shard(64)마다 Mutex
교체 정책 TinyLFU (확률적 빈도) CLOCK (Referenced ↔ Cold 감쇠)
슬롯 배치 동적 hash map 고정 32-way set-associative 배열
값 타입 임의 (Clone) Copy 제약 (u64 정도 크기 값에 적합)
동시성 한 mutex에 병목 가능 64-way sharding으로 대부분 non-blocking

즉 쓰기 처리량(write throughput)과 lock contention에 훨씬 강한 새 hot-path 캐시로 읽힌다. 다음 커밋에서 어느 hydrator(TES author_id? Gizmoduck AuthorFeatures? SafetyLabel expiring cache?)의 백엔드로 연결되는지가 주요 관측 대상이다.

새로 알게 된 것F. 랭커 Phoenix의 proto에서 지면 상수 두 개와 행동 집계 유형 하나가 이름을 얻었다

  • Phoenix(Grok 기반 Transformer 랭커)의 랭킹 wire proto에 Trend·Explore Spotlight 지면 상수와 긴 체류 몰입형 행동의 집계 상수가 새로 생겼다 확인 — phoenix/crates/serving/xai-recsys-proto/proto/recsys.proto

    • 함께 바뀐 파일: phoenix/python/common/xai-proto/proto/recsys.proto
    • 새 상수는 TREND_PAGE·EXPLORE_SPOTLIGHT 두 지면 상수와 IMMERSIVE_LONG_DWELL action aggregation 상수다.
    • enum 값으로는 ProductSurface 두 개(TREND_PAGE=16, EXPLORE_SPOTLIGHT=17)와 UserActionAggregationType::IMMERSIVE_LONG_DWELL=29다.
  • ProductSurface enum의 16·17번이 각각 Trend 지면과 Explore Spotlight 지면의 이름을 얻었다(이전은 PLACEHOLDER_16·PLACEHOLDER_17) 확인(같은 커밋). 4세대+3에서 정본에 반영된 15개 상수 뒤에 두 지면이 추가된 것이다.

    • 바뀐 enum:

      enum ProductSurface {
        // ... 1-15 as before
        PRODUCT_SURFACE_HOME_TIMELINE_RANKED_FOLLOWING = 15;
        PRODUCT_SURFACE_TREND_PAGE = 16;
        PRODUCT_SURFACE_EXPLORE_SPOTLIGHT = 17;
        PRODUCT_SURFACE_PLACE_HOLDER_18 = 18;
        PRODUCT_SURFACE_PLACE_HOLDER_19 = 19;
        PRODUCT_SURFACE_PLACE_HOLDER_20 = 20;
      }
      
  • 실제 홈믹서 연결은 아직 없어서, 이 두 상수를 Phoenix에 넣어 랭킹시키는 코드는 이번 커밋에 등장하지 않는다 확인(같은 커밋). 지금은 proto 슬롯이 준비됐다는 신호에 그친다.

  • UserActionAggregationType에서는 이전 PLACEHOLDER_29가 IMMERSIVE_LONG_DWELL = 29로 이름을 얻었다 확인(같은 커밋).

    • 바뀐 enum:

      enum UserActionAggregationType {
        // ...
        IMMERSIVE = 26;
        ADS_CHAD_WITH_CONV_TYPES = 27;
        ADS_WITH_KV_AD_HISTORY = 28;
        IMMERSIVE_LONG_DWELL = 29;
        PLACEHOLDER_30 = 30;
        // ...
      }
      
  • 기존 IMMERSIVE = 26(immersive 지면에서의 상호작용 시퀀스 aggregation)과 별개로, 긴 dwell 시간을 갖는 immersive 상호작용을 별도 aggregation 유형으로 분리할 수 있게 됐다 확인(같은 커밋).

  • 이 새 aggregation을 어떤 학습 config가 소비하는지는 이번 커밋에 나오지 않는다 확인(같은 커밋).

새로 알게 된 것G. two-tower 모델의 user tower가 padding 위치에 별도 segment ID를 쓰게 됐다

  • two-tower(유저 쪽과 후보 쪽을 따로 임베딩하는 retrieval 모델 구조)의 user tower가 padding 위치에 별도 segment ID를 쓰도록 수정됐다 확인 — phoenix/xrex/models/recsys_two_tower_model.py (+15/−4)

    • padding 위치에는 PADDING_SEGMENT_ID, 실제 history 위치에는 HISTORY_SEGMENT_ID를 쓴다. 기존 all-zero에서 mask를 반영하는 쪽으로 바뀌었다.
  • 이전 user_tower_segment_ids 로직은 두 갈래였다 확인(같은 커밋).

    • use_history_segment_ids=True면 배열 전체를 HISTORY_SEGMENT_ID로 채웠다. padding까지 history와 같은 segment로 취급하는 부정확한 방식이다.

    • False면 jnp.zeros로 모든 위치를 0으로 취급했다. RoPE(회전 위치 인코딩)·attention bias가 padding 위치에서도 계산됐다.

    • 이전 코드:

      def user_tower_segment_ids(batch: int, seq_len: int, *, use_history_segment_ids: bool) -> jax.Array:
          if use_history_segment_ids:
              return jnp.full((batch, seq_len), HISTORY_SEGMENT_ID, dtype=jnp.int32)
          return jnp.zeros((batch, seq_len), dtype=jnp.int32)
      
  • 새 로직은 padding_mask가 넘어오면 mask 값에 따라 HISTORY_SEGMENT_ID와 PADDING_SEGMENT_ID를 섞는다 확인(같은 커밋).

    • 이후 코드:

      def user_tower_segment_ids(
          batch: int,
          seq_len: int,
          *,
          use_history_segment_ids: bool,
          padding_mask: jax.Array | None = None,
      ) -> jax.Array:
          if use_history_segment_ids:
              return jnp.full((batch, seq_len), HISTORY_SEGMENT_ID, dtype=jnp.int32)
          if padding_mask is None:
              return jnp.zeros((batch, seq_len), dtype=jnp.int32)
          return jnp.where(padding_mask, HISTORY_SEGMENT_ID, PADDING_SEGMENT_ID).astype(jnp.int32)
      
    • 호출부:

      user_segment_ids = user_tower_segment_ids(
          B, T,
          use_history_segment_ids=self.config.use_history_segment_ids,
          padding_mask=user_padding_mask,
      )
      
  • 이제 user tower의 attention·RoPE·attention bias는 padding 위치에서 실제 history와 구분되어 계산된다 확인(같은 커밋). 4세대+15(2026-09-04, 9b0dc31)에서 정본에 반영된 "FA4 attention 커널 가변길이 히스토리 원생 지원"과 같은 방향의 정확성 보완이다.

새로 알게 된 것H. 광고 삽입 로그에 광고 블렌더 라벨이 붙는다

  • 홈믹서의 광고 삽입(injection) 로그에 광고 블렌더 라벨을 남기는 필드가 생겼다 확인 — home-mixer/side_effects/ads_injection_logging_side_effect.rs (+5/−1)

    • 필드 이름은 ads_blender이고, diff는 짧다:

      ads_blender: match query.request_type {
          RequestType::Following => "following".to_string(),
          _ => query.params.get(AdsBlenderType),
      },
      
  • 광고 injection 로그 payload에 붙는 ads_blender 값은 요청 유형에 따라 정해진다. RequestType::Following이면 하드코딩된 "following"을 쓰고, 그 밖의 지면에서만 AdsBlenderType feature-switch param을 조회한다 확인(같은 커밋).

  • 4세대+10(2026-08-28, 24c6094)이 도입한 광고 블렌더 다섯 개에 이어, 팔로잉 지면의 별도 블렌더 라벨 "following"이 로깅 축에 정식으로 들어온다 확인(같은 커밋).

    • 다섯 블렌더는 safe_gap·partition_organic·partition_organic_low_risk·time_gap·multi_risk다.
  • 이것이 여섯 번째 블렌더인지, 아니면 팔로잉 지면은 다섯 블렌더 밖에서 별도 파이프라인을 타므로 로깅에만 라벨이 잡히는 것인지는 이번 diff만으로는 확정할 수 없다 추정. 후자가 코드 흐름과 더 어울린다 추정. param 조회를 아예 건너뛴다는 것이 서로 다른 파이프라인이라는 신호로 읽힌다.

새로 알게 된 것I. 라벨 투명성 툴(UTH)에 국가별 계정 takedown 일일 배치가 생기고, 라벨 집계에 국가 필드가 붙었다

  • Under-The-Hood(UTH, X의 라벨 투명성 툴) 데이터 파이프라인에 국가별 계정 takedown 일일 배치 잡이 새로 생겼다 확인 — under-the-hood/scalding/UthPctdAccountTakedownEventsJob.scala

    • 잡 이름은 UthPctdAccountTakedownEventsJob이고, 신규 배치 잡은 143줄이다.
  • UTH 라벨 aggregate에는 countryCodes 필드가 붙었다 확인 — under-the-hood/thrift/uth_serving.thrift

  • under-the-hood/는 X가 x.com/i/under_the_hood로 노출하는 유저 개인 계정·포스트 라벨 투명성 툴의 백엔드다 확인(같은 커밋).

  • 배치 잡은 두 소스를 조인한다 확인(같은 커밋).

    • AuditServicePctdActionsScalaDataset은 PCTD(post·content takedown) action 로그다. 계정 유형이고, takendown에 성공했고, country가 있는 것만 남긴다: .filter { a => a.type == PctdActionType.User && a.takendown && a.countryCode.nonEmpty }.
    • UsersourceScalaDataset은 user 스냅샷의 user.takedowns.takedownCountryReasons다. reason은 TakedownReason::LegalRequest → "LEGAL_REQUEST", BystanderReport → "BYSTANDER_REPORT", Dmca → "DMCA"(country는 "xy"로 하드코딩, 국가 정보 없음), UnspecifiedReason → "UNSPECIFIED"로 바뀐다.
  • 같은 (userId, country)에 reason이 여러 개 잡히면 우선순위 값이 낮은 것을 남긴다 확인(같은 커밋).

    • 우선순위 표:

      private val ReasonPriority = Map(
        "LEGAL_REQUEST" -> 0,
        "BYSTANDER_REPORT" -> 1,
        "DMCA" -> 2,
        "UNSPECIFIED" -> 3
      )
      
  • 결과는 daily_account_takedowns/YYYY/MM/DD 파티션 파케이에 UthAccountTakedownEvent(userId, day, reason, country)로 쓴다 확인(같은 커밋).

    • 기본 reducers는 50, 기본 usersourceSnapshotMaxAgeDays는 10, Cross-DC 스냅샷 접근(AllowCrossDC)은 허용한다.
    • 테스트 유저 필터(TestUserFilter.isTestUserId)로 test 계정을 뺀다.
  • 동시에 uth_serving.thrift에서 두 스트럭트가 국가 필드를 얻는다 확인(같은 커밋).

    • 바뀐 스트럭트:

      struct UthPostLabelAggregate {
        1: optional string label (personalDataType = 'TweetSafetyLabels')
        2: optional list<UthDayCarriedRemoved> days
        3: optional list<string> countryCodes  // NEW
      }
      
      struct UthAccountLabelAggregate {
        1: optional string label (personalDataType = 'UserSafetyLabels')
        2: optional list<i32> activeDays
        3: optional list<string> countryCodes  // NEW
      }
      
  • 정리하면 UTH 투명성 툴은 두 가지를 새로 갖춘다 확인(같은 커밋).

    • 매일 계정별로 어느 나라에서 언제 어떤 reason으로 takedown되었는지의 이벤트 로그를 자체 데이터셋으로 갖게 된다.
    • label aggregate에 국가 코드 리스트를 노출할 준비가 됐다.
  • 이 변화는 4세대+12(2026-09-01, 6384ca7)에서 NSFW 게이팅 대상 16개국이 코드에 처음 명시된 것과 짝을 이룬다. 어떤 라벨이 어떤 나라에서 활성이었는지를 유저가 UTH에서 볼 수 있게 하려는 계획으로 읽힌다. UTH 화면에 국가별 필터나 표시가 등장하는지 지켜본다.

새로 알게 된 것그 밖의 변경

  • Safety-label expiring cache가 초기화할 때 쓸 capacity를 미리 예약해, 첫 삽입에서 재할당을 피한다 확인 — visibility-filtering/safety_label_source/expiring_cache.rs
    • ExpiringCache::with_clock이 초기화 시 cache.reserve(capacity)를 호출한다.
  • hydration 메트릭 코드에서 죽은 keyed-result 경로가 제거되고, 저자 라벨 집계 헬퍼가 새로 붙었다 확인 — visibility-filtering/hydration/metrics.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)다.
  • grox(Grok 기반 nearline 분류기 서브레포)의 motion-reveal 썸네일 생성이 프레임을 더 많이 받고, 정합·denoise·색채 검증을 단계별로 통과시키도록 정제됐다 확인 — grox/libs/video_tools/image.py (+288/−25)
    • 썸네일 생성은 video에서 single frame 하이라이트를 만드는 일이다. 이를 맡는 motion-reveal 이미지 빌더(video → 하이라이트 프레임 생성기)에 ECC affine 정합과 Farneback optical flow refinement 두 단계가 추가됐다.
    • 바뀐 점을 모으면 32 → 48 프레임 상한, ECC affine 정합, Farneback optical flow refinement, weighted median, LAB 스킨 색상 plausibility 게이트다.
    • _MOTION_REVEAL_MAX_INPUT_FRAMES가 32→48로 바뀌었고, 새 헬퍼가 여섯 개 생겼다.
      • _ecc_align: cv2.findTransformECC로 affine 변환을 정합한다.
      • _is_static: cv2.phaseCorrelate로 정적 프레임을 판정한다. 이동량과 phase correlation response가 둘 다 임계 이하면 정적이다.
      • _dense_align_residual: Farneback dense optical flow로 residual을 미세 정합한다.
      • _weighted_median: 정합 후 shift한 오버레이의 pixel별 가중 중앙값을 구한다.
      • _native_chroma: LAB 색공간에서 밝은 픽셀 상위 30%의 중앙 chroma 좌표를 구한다.
      • _skin_plausible: 오버레이의 chroma 중앙이 스킨색 축에서 거리 12 이하인지 보는 게이트다. false면 skin overlay라는 오탐을 막는다.
    • 4세대+12(2026-09-01)에서 정본에 반영된 PlanSafetyPtosSpecialVideo DAG가 이 motion-reveal 프레임을 소비하므로, 이번 정제는 그 DAG의 판정 품질에 직접 영향한다 추정.
  • home-mixer/params/param.rs의 feature-switch defaults 미러는 timestamp가 2026-09-09T16:23:17Z → 2026-09-10T16:21:03Z로 갱신된 것 외에 실질 변경이 없다.

"pub(crate) const HYDRATION_TIMEOUT: Duration = Duration::from_millis(390);" — visibility-filtering/hydration/mod.rs:28, 2026-09-10

"let author = raw.request_author_id.or_else(|| core.get(&raw.tweet_id).map(|c| c.author_id)).or_else(|| recovered_authors.get(&raw.tweet_id).copied())?;" — visibility-filtering/models/mod.rs:45-49, 2026-09-10

정본 반영 services/x.md ## 안전성 절에 ### 4세대+19 소절을 새로 만들어 A~J 열 개 축을 적었고, 문서 머리의 최종 갱신을 2026-09-11에서 2026-09-12로 옮겼다. B(AuthorId fallback)와 D(gRPC deadline 관측)는 안전 파이프라인의 새 데이터 포인트라서 정본에 명시적으로 반영했다. F(TREND_PAGE·EXPLORE_SPOTLIGHT·IMMERSIVE_LONG_DWELL)와 G(padding segment ID)는 랭킹 wire와 학습 설정의 변화라서 함께 반영했다. E(clock_cache primitive)는 아직 연결되지 않았지만, 다음 커밋에서 소비자가 붙을 것으로 예상되어 명시적으로 문서화했다.

의미 이번 커밋은 timeout을 늘려 판정 성공률을 높이는 동시에 부분 판정 경로를 열어, fail-open(판정에 실패하면 통과시키는 방식) 대신 partial-open으로 강건성을 얻는 방향이다. 4세대 공개 이래 이어진 visibility-filtering 강건성 개선 흐름의 연장선에 있고, 이번 4세대+19는 그 위에 timeout 예산 상향, fallback 캐시, 라벨 자료구조 축소, phase 관측을 더했다.

지금까지 이 흐름의 주요 이정표는 다음과 같다.

  • 4세대+4(2026-08-19, dark_traffic_setup.rs)의 shadow deployment 미러링
  • 4세대+7(2026-08-24, reference_compare.rs)의 verdict 대조 canary
  • 4세대+13(2026-09-01, 7ba7768)의 rules 재구조화
  • 4세대+15(2026-09-04, 9b0dc31)의 hydration 800줄 순 감소 단순화

⚠ 갱신할 주장은 없다. 이번 diff가 뒤집는 이전 정본 진술은 없고, VF가 fail-closed라는 canon(이 저장소의 정본 문서) 주의 목록 #1은 여전히 유효하다. hydration 실패로 판정하지 못한 tweet은 여전히 건너뛰며, author fallback은 건너뛰던 것을 부분 판정으로 바꿀 뿐이다.

레이더

Kuaishou의 캐스케이드 추천 논문 한 편을 radar.md의 "이번 달 신규"에 추가했다. 논문이 제안한 UniRec은 캐스케이드 추천의 pre-ranking과 ranking 단계 fusion을 두 stage의 joint learning으로 정렬하는 방법이고, Kuaishou 프로덕션 A/B에서 app usage duration을 +0.616% 높였다 확인 — arXiv 2609.11052, 2026-09-10 제출.

  • 다루는 문제는 산업용 추천의 캐스케이드 stage(pre-ranking, ranking)가 각기 다른 objective·feature space·latency 제약 아래에서 따로 최적화되는 것이다 확인(같은 논문). upstream은 downstream이 선호하는 아이템을 걸러 내고, downstream fusion은 upstream 개선을 상쇄한다.
  • 두 fusion agent가 input embedding을 일부 공유하고 single computation graph에서 함께 학습되어, gradient가 stage 사이를 흐른다 확인(같은 논문).
  • Dual-axis preference alignment는 두 방향으로 작동한다 확인(같은 논문).
    • 세로(vertical): downstream pairwise preference를 upstream fusion score에 전이한다.
    • 가로(horizontal): 이질적 prior signal에 대한 수십 개 pairwise objective를 bidirectional preference evidence로 재편한다.
  • 또 attribute group-relative regularization으로, 제약 없는(unconstrained) end-to-end fusion이 특정 attribute 그룹에 과집중하는 부작용을 억제한다 확인(같은 논문).
  • 21일 프로덕션 A/B에서 앱 사용시간이 +0.616% relative gain을 보였고, Kuaishou 플랫폼에 fully deployed됐다 확인(같은 논문).

다음에 볼 것