0x00

리서치 / 일일 리포트

  • 읽은 자료 1건
  • 정본 반영 1개 파일
  • 레이더 0건
한 줄 요약X의 노출 판정 필터가 이제 필터링 사유가 아니라 명시적인 액션 값을 보고 포스트를 거른다. 걸러지는 결과는 대부분 그대로다.

1노출 판정 필터가 사유 대신 액션 값으로 포스트를 거른다 — X

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

배경 X는 For You 파이프라인 소스코드를 xai-org/x-algorithm에 매일 두 번 정도 미러링한다(2026-08-13 4세대 공개 이래). 이번 커밋은 파일 28개짜리 중형이지만 서로 다른 5개 축을 동시에 건드렸다.

  • 노출 판정은 이제 사유가 아니라 액션 값이 결정한다. visibility-filtering(VF)은 포스트마다 '이 뷰어에게 보여도 되는가'를 판정하는 서비스다.
    • VF 클라이언트가 TweetVisibility에 명시적 Action 필드를 추가했고, home-mixer(For You 피드를 조립하는 서비스)의 VFFilter는 이제 FilteredReason이 아니라 Action enum으로 drop/keep을 결정한다.
    • 판정 축이 "reason에서 파생된 action"에서 "action이 결정, reason은 로깅용 annotation"으로 옮겨졌다.
  • 홈 타임라인에 머리 이미지, 본문, 1~2개 CTA(행동 유도) 버튼으로 구성되는 새 광고 포맷이 first-class로 등장했다. 트윗을 감싸는 프로모트 트윗 포맷이 아닌 6번째 변종이다.
    • home-mixer/util/urt/ad_marshaller.rs에 header_image_prompt_ad_info가 있는 광고를 별도 marshaller로 처리하는 분기가 신설됐다.
  • 콘텐츠 분류 쪽에서는 긴 영상의 첫 프레임이 LLM 프롬프트에 따로 들어간다.
    • grox(Grok 기반 nearline 분류기 서브레포) 미디어 로더가 30초+ 영상에 대해 첫 프레임 preview 이미지를 다운로드·리사이즈한다.
    • 이 이미지는 LLM 프롬프트에 [Video Preview Thumbnail] 라벨과 함께 storyboard(균일 샘플링된 프레임을 격자로 붙인 이미지) 앞에 붙는다.
  • 라이브 방송 프레임이 두 안전 파이프라인의 정식 판정 대상이 됐다.
    • BroadcastLoader가 grox에 새로 들어와 라이브 broadcast(X의 라이브 비디오, Periscope 후신)의 프레임을 추출한다.
    • PTOS(정책 기반 위반 카테고리 판정) adult-content leading-frames와 sex/nudity safemodel, 두 안전 파이프라인이 broadcast 프레임을 정식 payload로 인정한다.
  • 등록된 랭킹 훈련 config 두 개 모두에서 멀티모달 post 임베딩이 학습 입력에서 빠졌다.
    • Phoenix(X For You의 랭킹 모델) 문서 phoenix/README.md의 nano 훈련 config 표에서, multimodal post 임베딩이 xrecsys_seqpack에서 v5로 켜져 있다는 줄이 "off (home_direct_packed and xrecsys_seqpack)"로 바뀌었다.
  • 부수적으로 phoenix NUMA pinning이 isolated CPU를 sched_setaffinity 대상에서 빼도록 고쳐졌고, candidate-pipeline의 quiet-spans feature-gated 로그 6곳이 삭제됐다.

새로 알게 된 것A. VF 판정에 명시적 액션 값이 생겼고, 필터는 이 값으로 거를지 정한다

  • VF의 판정 축이 필터링 사유에서 명시적 액션 값으로 리팩터됐다 확인 — visibility-filtering-client/vf_client.rs (+130/−62).
  • 클라이언트가 돌려주는 판정 결과는 이제 세 값으로 된 튜플이다 확인.
    1. 결정론적 판정 Action(Allow / Interstitial / Avoid / Downrank / Drop / Tombstone / NotEvaluated)
    2. 로깅과 다운스트림용 annotation FilteredReason
    3. 라벨 lookup 결과 SafetyLabelMap
    • TweetVisibility는 기존 reason: Option<FilteredReason>과 safety_labels에 더해 action: Action 필드를 정식 멤버로 갖게 됐다.
    • 새 helper TweetVisibility::to_visibility_reason()이 action과 reason을 조합해 visibility_reason 필드 값을 유도한다.
  • Strato(코드가 데이터를 조회하거나 저장할 때 거치는 X 내부 시스템) 경로에서는 클라이언트가 Strato에서 받은 사유로부터 액션을 계산한다 확인. 규칙은 세 갈래다.
    • Strato의 정의는 services/x.md 용어 표에 있다.

    • StratoVfClient::get_result가 Strato에서 받은 FilteredReason으로부터 action_from_strato_reason을 거쳐 action을 계산한다.

      fn action_from_strato_reason(reason: &Option<FilteredReason>) -> Action {
          match reason {
              None => Action::Allow,
              Some(FilteredReason::SafetyResult(safety_result)) => match safety_result.action {
                  Action::NotEvaluated => Action::Allow,
                  _ => safety_result.action.clone(),
              },
              Some(_) => Action::Drop(DropReason {}),
          }
      }
      
    • Strato가 준 사유가 SafetyResult이면 그 안의 action을 그대로 쓰고, 그 밖의 non-SafetyResult reason(뮤트·차단·null-cast·exclusive 등)은 무조건 Drop으로 만든다.

    • Strato가 NotEvaluated로 리턴하면 client 층에서 Allow로 승격된다. 이는 Strato가 판정 미실행을 fail-open으로 취급하겠다는 뜻이다.

  • gRPC 경로에서는 응답에 담긴 액션을 그대로 쓴다 확인. 새 gRPC 경로에서는 서버가 action을 결정해서 내려주는 것이 표준이다.
    • result_to_visibility가 gRPC 응답의 result.action을 그대로 Action으로 매핑한다.
    • action 필드가 없으면 Action::NotEvaluated가 된다.
  • 필터 층은 이제 후보의 액션 값으로 거를지 정한다 확인. Allow, Interstitial, Avoid, Downrank는 남기고 Drop, Tombstone, NotEvaluated는 거른다.
    • VFFilter::filter가 이제 candidate.visibility_action의 함수다. 판정 함수는 다음과 같다.

      pub(crate) fn should_drop_action(action: &Action) -> bool {
          match action {
              Action::Allow | Action::Interstitial | Action::Avoid | Action::Downrank => false,
              Action::Drop(_) | Action::Tombstone | Action::NotEvaluated => true,
          }
      }
      
    • 이전 버전은 FilteredReason variants를 매칭했다. SafetyResult면 action == Drop일 때만 걸렀고, 나머지 non-SafetyResult reason(뮤트·차단·null-cast·exclusive 등)은 무조건 drop이었다.

    • 새 버전은 판정 축이 명시적이라 훨씬 읽기 좋다.

  • 조상, 인용, 리트윗 원문 포스트를 거를 때도 액션 값을 본다 확인.
    • vf_candidate_hydrator.rs의 should_drop_ancillary가 조상·quoted·retweeted 포스트의 visibility.action을 봐서 drop 여부를 결정한다.
    • 이전 should_drop_reason(reason: &FilteredReason)은 제거되고, filter 층의 should_drop_action을 공유한다.
  • 후보 객체에 액션 필드가 추가됐다 확인.
    • candidate.rs의 PostCandidate가 visibility_action: Option<Action>을 갖게 됐고, 기존 visibility_reason: Option<FilteredReason>은 유지된다.
    • Hydrator update 함수 두 개(VFCandidateHydrator::update·VFFollowingCandidateHydrator::update)가 이 필드도 함께 채운다.
  • 뷰어가 작성자를 차단한 경우를 가리키는 사유 이름이 새로 생겼다 확인.
    • 새 FilteredReason 변종은 FilteredReason::ViewerBlocksAuthor다. 기존에는 AuthorBlockViewer(작성자가 뷰어를 차단)만 있었다. 이제 대칭 방향인 '뷰어가 작성자를 차단'도 정식 reason 이름을 갖는다.
    • vf_pb::FilteredReason::ViewerBlocksAuthor와 proto to/from 매핑도 추가됐다.

⚠ 주장 변경(services/x.md line 392의 "fail-closed" 진술): fail-closed 성격은 유지되지만, 판정 세분화는 훨씬 명확해졌다.

  • 이전 정본은 "VFFilter는 fail-closed다. SafetyResult 이외의 모든 FilteredReason 변종이 무조건 drop이다"였다.
  • 새 판정 함수는 문법이 완전히 달라졌지만 결과는 대부분 동일하다. Strato 경로에서 non-SafetyResult reason은 client 층에서 이미 Drop으로 변환되고, NotEvaluated도 drop이다.
  • 이전 필터도 SafetyResult로 온 판정은 action이 Drop일 때만 걸렀다 확인 — vf_filter.rs:22-30. Allow·Interstitial·Avoid·Downrank·Tombstone·NotEvaluated는 모두 남겼다.
  • 바뀐 것은 필터 층에서 Tombstone과 NotEvaluated가 이제 걸러진다는 점이다 확인 — 아래 인용한 vf_filter.rs:16-21.

"match action { Action::Allow | Action::Interstitial | Action::Avoid | Action::Downrank => false, Action::Drop(_) | Action::Tombstone | Action::NotEvaluated => true, }" — home-mixer/filters/vf_filter.rs:16-21, 2026-09-09

의미 (A) 이번 리팩터는 클라이언트 층에서 판정을 통일된 액션 값으로 좁힌 뒤, downstream에는 action만 흐르게 만든 것이다.

  • 통일된 액션 값의 타입은 Action enum이다.
  • 4세대 공개(2026-08-13) 이래 VF의 판정 함수에는 세 경로가 얽혀 있었다. SafetyResult.action을 봐야 하는 경로, FilteredReason variant를 봐야 하는 경로, gRPC action을 매핑해야 하는 경로다.
  • 4세대+7(2026-08-24, d011592)이 reference_compare.rs로 도입한 shadow 비교에서 diff의 원인은 대부분 "reason variant matching이 파이프라인마다 조금씩 달라서"였을 가능성이 크고 추정, 이번 리팩터는 그 diff를 줄이는 후속 조치로 읽힌다.
  • 아직 Downrank를 실제로 쓰는 코드는 이 커밋에 등장하지 않는다. Strato가 리턴하는 SafetyResult.action에 Downrank가 들어올 여지는 열렸지만, 실제로 연결되는지는 다음 커밋에서 볼 대상이다.

새로 알게 된 것B. 홈 타임라인에 트윗을 감싸지 않는 여섯 번째 광고 포맷이 생겼다

  • 홈 타임라인에 새 광고 포맷이 신설됐다 확인 — home-mixer/util/urt/ad_marshaller.rs (+258/−3).
    • 포맷 이름은 header-image-prompt-ad다.
  • 기존 홈 타임라인 광고는 다섯 포맷이었고, 이번 커밋이 여섯 번째를 추가했다 확인.
    1. 프로모트 트윗(promoted-tweet-<post_id>-<impression>)
    2. preroll 비디오 광고
    3. SSP 트윗/RTB Trend Takeover
    4. RTB image ad (rtb-image-ad-<impression>)
    5. Google SDK Native ad(GoogleSDKNativeAd)
    • 여섯 번째의 엔트리 이름은 header-image-prompt-ad-<impression>이다.
  • 광고에 이 포맷의 정보가 있으면 전용 marshaller가 우선 처리한다 확인. 프로모트 트윗과는 상호 배타적으로 분기된다.
    • marshal_ad() 함수 최상단에 이 분기가 있다.

      if let Some(info) = &ad.header_image_prompt_ad_info {
          return marshal_header_image_prompt_ad(ad, info, sort_index, request_type, &impression_string);
      }
      
    • 판단 기준은 AdIndexInfo.header_image_prompt_ad_info: Option<HeaderImagePromptAdInfo> 필드가 있느냐다.

  • 입력 정보(HeaderImagePromptAdInfo)는 광고 인덱스 서비스가 채워 넣는 proto message이고, 필드 구성은 marshaller가 소비하는 형태에서 역산할 수 있다 확인.
    • 헤더 이미지: image_url: String, image_width/height
    • 헤더와 본문 문구: header_text: String, body_text: String
    • 주 CTA: button_text: String, landing_url: String
    • 보조 CTA(비어 있으면 표시하지 않음): secondary_button_text: String, secondary_landing_url: String
    • 클릭 후 광고 dismiss 여부: dismiss_on_click: bool
    • 노출 트래킹 콜백: impression_callback_endpoints: Vec<String>
    • 주 CTA 클릭 트래킹 콜백: click_callback_endpoints: Vec<String>
  • 이 광고는 URT(Universal Response Tree, X의 응답 렌더링 스키마)에 트윗이 아니라 Message 타입 아이템으로 실린다 확인. "메시지 프롬프트"라는 별도 타입이다.
    • 엔트리 구조는 TimelineEntryContent::Item(TimelineItem { content: TimelineItemContent::Message(MessagePrompt { content: MessageContent::HeaderImagePrompt(HeaderImagePrompt { .. }), impression_callbacks }) })다.
    • MessageContent::HeaderImagePrompt가 이 광고 전용 컨텐츠 variant다.
  • 프롬프트 컨텐츠(HeaderImagePrompt)에는 이미지, 문구, 버튼 동작이 담긴다 확인.
    • 이미지(image_variants에 URL·width·height가 담긴다): header_image: MessageImage
    • 헤더 문구: header_text: String, header_rich_text: RichText. plain text와 entity 없는 RichText, 두 형태로 함께 담긴다.
    • 본문(있을 때만): body_text: Option<String>, body_rich_text: Option<RichText>
    • 주 버튼(필수): primary_button_action: MessageTextAction
    • 보조 버튼(secondary_button_text가 비어 있지 않을 때만): secondary_button_action: Option<MessageTextAction>
  • 버튼마다 자체 클릭 이벤트 정보와 callback 리스트를 가진다 확인.
    • 버튼 구조(MessageTextAction)는 text: String과 action: MessageAction { dismiss_on_click, url, client_event_info, on_click_callbacks, on_click_reactive_trigger }로 이뤄진다.
    • 각 버튼의 ClientEventInfo에서 action은 "primary_cta" 또는 "secondary_cta"다.
  • 테스트 3개가 이 계약을 고정한다 확인.
    1. header_image_prompt_ad_marshals_to_message_prompt_entry: entry_id 형식("header-image-prompt-ad-fa4ead01"), impression callback, HeaderImagePrompt content variant, 이미지 URL/치수, 텍스트, 주 CTA URL/dismiss/callback을 assert한다.
    2. header_image_prompt_ad_emits_secondary_button_when_set: secondary_button_text = "Learn more"를 세팅하면 secondary_button_action이 존재하고 action이 secondary_cta다.
    3. header_image_prompt_ad_info_absent_stays_a_tweet_item: header_image_prompt_ad_info: None이면 기존 프로모트 트윗 marshaller로 fall-through한다.

의미 (B) X 홈 타임라인이 트윗을 감싸지 않는 광고 포맷을 새로 정식으로 넣었다.

  • RTB image ad도 이미 트윗과 분리된 이미지 광고였지만, header-image-prompt-ad는 헤더 이미지, 본문 텍스트, 최대 2개 CTA 버튼으로 이뤄진 훨씬 풍부한 컴포넌트 구성을 갖는다.
  • URT MessageContent라는 별도 컨텐츠 타입을 쓰는 광고는 이 포맷이 첫 사례다. 클라이언트 렌더링 관점에서는 트윗이 아니라 in-feed announcement/dialog UI로 렌더링될 가능성이 높다 추정.
  • Secondary CTA를 지원하는 광고 포맷도 홈 타임라인에서는 이번이 첫 진입이다.
  • 광고 인덱스 시스템(공개 안 됨)이 이 광고 포맷을 언제 어떤 조건에서 채우는지는 이 커밋에 없다. 광고 블렌더 5종(safe_gap·partition_organic·partition_organic_low_risk·time_gap·multi_risk) 가운데 어느 것에 진입하는지도 없다.

새로 알게 된 것C. grox가 30초가 넘는 영상의 미리보기 이미지를 storyboard 앞에 붙인다

  • grox 미디어 로더가 긴 영상에 preview thumbnail을 첨부한다 확인 — grox/core/data_loaders/media_loader.py (+100/−19).
  • 기준 길이, 이미지 필드, 라벨이 새로 생겼다 확인.
    • 기준 길이 상수는 _VIDEO_PREVIEW_MIN_DURATION_MS = 30_000(30초)이다.
    • Video pydantic 모델에 preview_image: bytes | None = None 필드가 추가됐다.
    • 라벨 상수는 PREVIEW_IMAGE_LABEL = "[Video Preview Thumbnail]"이다.
  • 미리보기는 설정 스위치가 켜져 있을 때 네 가지 진입 조건(4-way gate)을 거쳐 붙는다 확인.
    • hydrate_video가 기존 프레임 hydration 뒤에 if grox_config.media_hydration.enable_video_preview_image: await cls.hydrate_video_preview(video, is_main_post and is_high_fav)를 실행한다.
    • hydrate_video_preview의 진입 조건은 다음 넷이다.
      1. video.convo_video is not None
      2. video.url이 있다
      3. video.videoInfo가 있다
      4. (video.videoInfo.durationMillis or 0) > _VIDEO_PREVIEW_MIN_DURATION_MS: 30초 초과
  • 메인 포스트이면서 fav가 높으면 deluxe 전용 타일 크기로 리사이즈한다 확인.
    • is_deluxe = is_main_post and is_high_fav이면 deluxe_image_tile_size로, 아니면 image_tile_size로 resize_tile(bs, tile_size)한다.
    • 다운로드에 실패하면 media_loader.hydrate_video_preview_failed.count가 증가한다.
    • 성공하면 media_loader.hydrate_video_preview.count가 증가하고 video.convo_video.preview_image = preview가 된다.
  • LLM 입력에서는 라벨, 이미지, 줄바꿈이 storyboard 앞에 들어간다 확인.
    • Video.interleave_storyboard는 preview_image가 있으면 storyboard 리스트 앞에 [f"{PREVIEW_IMAGE_LABEL} ", self.preview_image, "\n"] 세 원소를 추가한다. storyboard composite 앞에 라벨, 원본 이미지 바이트, newline이 차례로 온다.
    • _image_part 기반 chat marshaling 경로에서도 if c.preview_image: parts.append({"type": "text", "text": PREVIEW_IMAGE_LABEL}); parts.append(_image_part(c.preview_image))로 그림 전에 텍스트 라벨을 붙인다.

의미 (C) 이번 커밋은 긴 영상의 대표 프레임 한 장을 storyboard 앞에 먼저 붙이는, 세 번째 미디어 표현 형태를 추가했다.

  • 4세대+12(2026-09-01, 6384ca7)에서 motion-reveal frames(정적/동적 분리)가 storyboard(4세대부터)에 더해진 이래, grox가 LLM에 넘기는 미디어 표현은 이 두 형태였다.
  • 짧은 영상은 이미 storyboard의 프레임 수가 적어서 대표성이 충분하다. 반면 30초+ 긴 영상은 프레임 격자로는 첫인상이 흐려질 수 있으므로, preview thumbnail로 "이게 무엇인가"를 먼저 잡아 준다.
  • Deluxe 티어(main post이고 high fav)면 tile 크기가 큰 preview를 쓴다는 점도, LLM 판정 비용을 대표성에 걸어 두는 기존 설계와 일관된다.

새로 알게 된 것D. 라이브 방송 프레임이 두 안전 판정 파이프라인의 대상이 됐다

  • grox가 라이브 방송(broadcast)의 프레임을 추출하고, PTOS 안전 파이프라인 두 곳이 이 프레임을 판정한다 확인 — grox/core/data_loaders/media_loader.py (broadcast 부분).
  • 미디어 로더에 방송 로더가 새로 들어왔다 확인.
    • media_loader.py에 from broadcast_tools.broadcast import BroadcastLoader가 추가됐고, MediaLoader._broadcast_loader: BroadcastLoader | None = None이 클래스 캐시다.
    • _get_broadcast_loader()가 lazy init한다. 이때 bearer_token=X_API_BEARER·guest_token=BROADCAST_GUEST_TOKEN 환경변수를 쓴다.
  • 방송에서 뽑은 프레임은 Video 객체로 정규화된다 확인.
    • _extract_broadcast_video(broadcast_metadata: BroadcastMetadata) -> Video가 BroadcastLoader.load_broadcast(broadcast_id, media_key=..., max_frames=config.video_max_frames, tile_size=config.video_tile_size, max_duration=broadcast_metadata.crop_seconds)로 프레임을 뽑는다.
    • 뽑은 프레임은 Video(convo_video=ConvoVideo(frames=..., duration=interval, total_duration=result.video.sampled_duration))로 정규화된다.
    • duration은 (frames[-1].time_sec - frames[0].time_sec) / (len(frames) - 1)로 계산한다.
  • 성인 콘텐츠 leading-frames 필터의 진입 조건이 두 곳에서 바뀌었다 확인. 대상은 4세대+17(2026-09-08)에서 정본에 반영한 6-gate 조건이다.
    • 조건이 있는 곳은 TaskSafetyPtosAdultContentLeadingFramesFilter._eligible_with_post다.
  • 첫째, Deluxe fav 게이트가 사라졌다. 이제 fav 임계와 무관하게 진입한다 확인.
    • 기존 게이트 #2 post.get_fav_count() < grox_config.media_hydration.deluxe_fav_count_threshold: return cls._skip(post, "not_high_fav")가 완전히 삭제됐다.
  • 둘째, 긴 영상이 없어도 broadcast가 있으면 진입한다 확인.
    • 기존 코드는 long_videos = [m for m in media if m.type == "video" and durationMillis >= 30s]만 봤고, 없으면 no_long_video로 건너뛰었다.
    • 새 코드는 broadcasts = [p.broadcast_metadata for p in (post, post.quoted_post) if p is not None and p.broadcast_metadata is not None and p.broadcast_metadata.broadcast_id]를 함께 계산한다. long_videos나 broadcasts 둘 중 하나라도 있으면 진입한다.
    • crop_seconds = 10.0이 broadcasts에도 세팅되고, Metrics.counter("...eligible.count")에 media = "both" | "broadcast" | "video" 태그가 붙는다.
  • sex/nudity safemodel 판정도 설정 스위치가 켜져 있으면 broadcast 프레임을 payload로 받는다 확인.
    • TaskSafetyPtosSafemodelSexNudity._collect_payloads에 if grox_config.media_hydration.enable_local_broadcast_frame_extraction: 분기가 추가됐다.
    • 이 분기는 _iter_broadcast_videos(post)로 broadcast video의 convo_video.frames에서 _MAX_FRAMES_PER_VIDEO = 8을 uniform 샘플링해 ("broadcast_frame", frame) payload로 append한다.
    • _FRAME_KINDS = {"video_frame", "broadcast_frame"} 상수로, has_video·n_video_frames를 계산할 때 두 종류를 동등하게 취급한다.
    • _iter_broadcast_videos는 post와 post.quoted_post 각각에서 broadcast_metadata.video.convo_video.frames가 있는 것만 yield한다.

의미 (D) 이번 커밋으로 라이브 broadcast가 정식 미디어 payload로 승격돼, 이미 정적 영상에 적용되던 두 안전 파이프라인(adult-content leading-frames와 safemodel sex/nudity)의 판정 대상이 됐다.

  • X의 라이브 비디오(broadcast, Periscope의 계승)는 이번 커밋 전까지 grox 안전 파이프라인의 사각지대였다. PTOS가 트윗의 정적 미디어(사진·업로드 영상)만 판정하고, 라이브 방송은 다른 시스템으로 넘어갔거나 미판정 상태였을 가능성이 크다.
  • 4세대+17에서 도입된 6-gate 가운데 하나였던 Deluxe fav 임계가 하루 만에 삭제됐다. leading-frames 파이프라인의 조건은 다음 5-gate로 줄었다.
    • (a) prior PTOS 결과 존재
    • (b) prior NSFW 아님
    • (c) 30초+ 영상 또는 broadcast
    • (d) prior AdultContent policy 또는 fav ≥ 512
  • 즉 재판정 대상 후보군이 급격히 늘어난다. 이 결정은 fav 임계로 재판정 부하를 관리하는 대신, (d)의 ADULT_CONTENT_LEADING_FRAMES_UNCONDITIONAL_FAV = 512와 (a)/(b)의 prior 조건으로 부하 제어를 옮긴다는 뜻으로 읽힌다 추정.

새로 알게 된 것E. 등록된 랭킹 학습 config 두 개 모두에서 멀티모달 포스트 임베딩이 꺼졌다

  • 랭킹 훈련 config에서 multimodal post 임베딩이 전부 꺼졌다 확인 — phoenix/README.md (+3/−3).
  • nano 훈련 config 표의 "Multimodal post embedding" 행이 바뀌었다 확인.
이전 (49815da) 이후 (75d93d9)
ranking nano v5 on xrecsys_seqpack; off on home_direct_packed off (home_direct_packed and xrecsys_seqpack)
  • 부수 설명 문구도 함께 바뀌었다 확인.

"the multimodal-embedding input is off in both, and on every registered ranking config, as the table shows" — phoenix/README.md, 2026-09-09

  • 문구의 on은 "off"의 문법적 elision으로 읽어야 한다("off in both, and [off] on every registered ranking config") 확인. 그렇지 않으면 표와 모순된다.
  • 즉 등록된 랭킹 훈련 config 두 개(home_direct_packed 플래그십, xrecsys_seqpack seqpack) 모두에서 multimodal post 임베딩이 학습 입력에서 빠졌다 확인.

의미 (E) 4세대+17(2026-09-08, 49815da)이 grox 검색 지면 전용 v8-search 멀티모달 임베딩 스택을 신설해, 랭킹 옆에 v5·v8.2와 별개인 벡터 공간을 열었다. 그 하루 뒤인 이번 커밋에서, v5가 켜져 있던 유일한 랭킹 config의 v5 입력이 꺼졌다.

  • 그 config는 xrecsys_seqpack이고, 4세대 공개 때부터 v5가 켜져 있었다 확인 — phoenix/README.md 표.
  • 두 사건을 조합하면 시나리오가 좁혀진다: v5는 랭킹 입력으로 유의미한 개선을 내지 못했거나 latency/quality tradeoff에서 밀렸고, v8-search로 넘어가는 중간에 학습 config에서 임시로 뗐다 추정.
  • v8-search를 랭킹 config에 연결하는 코드는 이 커밋에 없다. 검색 지면 임베딩과 랭킹 임베딩이 별도 벡터 공간으로 유지될지, v8-search가 v5를 대체할지는 다음 커밋에서 볼 대상이다.

새로 알게 된 것그 밖의 변경

  • NUMA 노드 안의 격리 코어(isolcpus)에 스레드를 스케줄하려던 Phoenix의 이전 버그가 고쳐졌다 확인.
    • 고친 곳은 phoenix/xrex/utils/gpu.py의 NUMA CPU affinity 처리다. 두 헬퍼 _parse_cpulist(text: str) -> set[int]와 schedulable_cpus(*nodes: int) -> set[int]가 신설됐다.
    • schedulable_cpus는 /sys/devices/system/node/nodeN/cpulist에서 노드 N의 CPU 집합을 읽고, /sys/devices/system/cpu/isolated를 빼서 반환한다.
    • pin_process_to_local_numa는 기존 numa.schedule.run_on_nodes(node) → numa.memory.set_membind_nodes(node)를 둘로 나눴다. numa.memory.set_membind_nodes(node)를 먼저, os.sched_setaffinity(0, schedulable_cpus(node))를 둘째로 실행해 예외 처리를 계층화했다.
    • 노드에 isolated가 아닌 CPU가 없으면 warn을 남기고 membind만 유지한다.
    • Phoenix 학습이 실 프로덕션 host에서 격리된 코어 configuration을 만난 결과로 보인다 추정.
  • 후보 파이프라인의 컴포넌트마다 남기던 feature-gated info 로그 6곳이 삭제됐다 확인.
    • candidate-pipeline의 quiet-spans 로그다. filter.rs·hydrator.rs·query_hydrator.rs·scorer.rs·selector.rs·source.rs에서 #[cfg(feature = "quiet-spans")] tracing::info!(...) 블록이 각 1개씩 제거됐다(총 −40줄).
    • 파이프라인 각 컴포넌트 실행마다 남기는 조용한 info-level 로그였다. quiet-spans feature 조건이 상시 활성화된 상태에서 span record와 중복되므로 제거된 것으로 보인다 추정.
    • Error 로그의 문자열 포맷도 error!(component = self.name(), error = %err, "source_failed")에서 error!("{} Failed: {}", self.name(), err)로 되돌아갔다. 구조화 필드가 제거되고 문자열 로그로 회귀했다.
  • Phoenix inference 코드도 조금 바뀌었다 확인. 이번 커밋에서는 API surface를 소소하게 정리한 것으로 보인다.
    • 바뀐 파일은 phoenix/xrex/configs/xrecsys.py (−1), phoenix/xrex/inference/model_runner.py (+2/−1), phoenix/xrex/models/recsys_model.py (+5)다.
    • 4세대+17(2026-09-08)의 ContentFeatures를 소비하도록 연결하는 작업일 가능성도 있으니, 다음 커밋에서 다시 볼 대상이다.

정본 반영 services/x.md 랭킹·필터 절 line 392의 VF fail-closed 진술을 갱신했다. 소절도 하나 새로 만들었다(## 파이프라인 아래 "4세대+18" subsection 신설). ## 출처에는 75d93d9 sources를 추가했다.

의미 (전체) 이번 커밋에서 코드 품질로 가장 큰 개선은 VF Action 리팩터이고, 제품에서 가장 눈에 띌 변화는 header-image-prompt-ad다. multimodal 임베딩 학습 config 제거는 X 랭킹 모델의 입력 스키마 결정에서 가장 무거운 신호다. 4세대 공개 때부터 켜져 있던 v5 입력을 v8-search 스택 신설 하루 뒤에 껐으므로, v8-search 연결의 후속을 지켜봐야 한다.

  • 4세대+17이 하루 사이에 다섯 축을 동시에 움직인 대형 커밋이었다면, 4세대+18에는 각 축의 정리·후속·확장이 골고루 실렸다.
    • VF는 판정 축을 통일했다(가장 크고 근본적인 리팩터).
    • 광고는 새 포맷을 추가했다(제품 기능 신설).
    • grox는 preview thumbnail로 LLM 입력을 개선했고, broadcast 통합으로 안전 파이프라인 커버리지를 넓혔다.
    • 학습 config는 multimodal 임베딩을 제거했다(설계 방향 결정).

검증에서 뒤집힌 것

  • "VFFilter는 fail-closed다"라는 정본 진술은 완전히 부정되지 않았고, 표현이 바뀌었다.
    • 위치는 services/x.md line 392다.
    • 판정 축이 FilteredReason 매칭에서 명시적 Action enum으로 옮겨졌다.
    • fail-closed 성격은 유지된다(NotEvaluated → drop, Strato 경로에서 non-SafetyResult reason → Drop 변환). 다만 필터 층에서 Tombstone과 NotEvaluated가 새로 걸러진다.
    • 정본에 반영했다.

레이더

오늘 레이더에 올린 항목은 없다. arXiv cs.IR 30건을 파싱했고, 수용 기준(플랫폼 기업 저자 또는 프로덕션 A/B 주장)을 통과한 것은 0건이다. 눈에 띈 후보는 다음 넷이다.

  • SequenceO1: End-to-End Ultra-Long (100K) Sequence Modeling in Recommendation with Low-Rank Caching
    • 4세대+17을 다룬 daily에서 이미 언급했다. 여전히 프로덕션 A/B가 없다.
  • PDMR: Passage-Driven Multi-ID Document Retrieval
    • Generative Retrieval 계열이다. 플랫폼 소속과 프로덕션 결과가 없다. 탈락했다.
  • Exploring Bottom-Up Clustering for Creating Semantic IDs
    • Semantic ID 라인의 클러스터링 방법론이다. 플랫폼 소속이 없고, 이론적 증분이다. 탈락했다.
  • Q2D-Web: A Large-Scale Benchmark for Retrieval in Agentic RAG Systems
    • Agentic RAG 벤치마크로, 도메인 밖이다.

다음에 볼 것