0x00

리서치 / 일일 리포트

  • 읽은 자료 2건: X 커밋 1건(4세대+2), arXiv triage 30건 중 통과 1건(SDF)
  • 정본 반영 1개 파일(x.md 7개 절 갱신)
  • 레이더 갱신 1건(Google Discover SDF 신규 등록)
  • Meta 엔지니어링 블로그 0건(마지막 관련 글은 2026-08-05, 정본에 이미 반영)
  • Netflix 테크 블로그 0건(마지막 글은 2026-08-07이고 알고리즘과 무관)
한 줄 요약X가 광고 브랜드 안전 판정에 V2 경로를 나란히 추가하고, 성인 컨텐츠 판정이 엇갈리면 Grok 4.5에게 다시 묻게 했다. 심판이 반대하면 hard 판정은 soft로 낮아진다.

오늘 함께 본 다른 변화는 다음과 같다.

  • X는 같은 커밋에서 favorite이 1개 이상인 후보만 retrieval 인덱스에 넣던 조건을 없앴다.
  • Google 저자들이 Discover 피드에서 2년간 운영한 staleness 필터(SDF)를 논문으로 공개했다. 사용자의 staleness 신고가 54.9% 줄었다고 밝혔다.

1광고 안전 판정에 V2 경로를, 성인 판정에 Grok 4.5 심판을 더했다 — X

자료 xai-org/x-algorithm @ b089ce6 — "Open-source X Recommendation Algorithm" — 2026-08-17 20:20 UTC, 공개 소스코드. 이 노트들은 4세대 공개 뒤의 X 커밋에 차례로 번호를 붙여 부르며, 이 커밋은 4세대+2다. 파일 34개, +751/−162다. 4세대+1(2026-08-14)보다 규모는 작지만, 광고 안전과 성인 컨텐츠 심판, retrieval 인덱싱이라는 서로 다른 세 축을 한 커밋에 함께 담았다.

배경 X 추천 알고리즘 공개 소스의 4세대(2026-08-13, 47c1bcd)는 초대형 공개로, 파라미터와 안전 파이프라인, labeling 서브레포 15개를 열었다. 다만 botmaker 규칙 일부는 게이밍 방지를 위해 공개하지 않았다. 바로 다음 날 나온 4세대+1(2026-08-14, c65aa17)은 다섯 방향으로 공개 범위를 넓혔고, 자세한 내용은 daily/2026-08-16.md에 정리해 두었다. 그 다섯 방향은 가중치 해석 규칙, 브라질 선거 필터, Thompson sampling, Phoenix(X For You의 랭킹·retrieval 모델) bool 3종, BDSM enforcement note 생성기 부분 공개다. 이번 커밋은 그 사흘 뒤(2026-08-17)에 나왔고, 규모가 훨씬 작은 대신 광고와 성인 컨텐츠 판정 계층에 초점을 분명히 맞췄다.

특히 이번 커밋의 세 방향이 정본에 영향을 준다. 광고 브랜드 안전 판정에 생긴 V2 경로(카드 A), 성인 컨텐츠 판정에 더해진 심판 계층(카드 B), Phoenix retrieval 인덱싱 게이트의 제거(카드 C)다.

새로 알게 된 것A. 광고 브랜드 안전 판정에 V1을 그대로 둔 채 V2 경로가 생겼다

광고 브랜드 안전 판정(포스트 옆에 광고를 붙여도 되는지 가리는 판정)에 V2 로직이 나란히 추가됐다. 4세대 초까지 정본은 compute_verdict가 MEDIUM_RISK_LABELS(14개)와 LOW_RISK_LABELS(3개)를 순서대로 검사해 광고를 붙일지 판단한다고 적어 왔다. 그러나 코드의 MEDIUM_RISK_LABELS는 4세대부터 15개였고, 정본이 하나를 빠뜨려 적어 왔다 확인 — brand_safety.rs@47c1bcd. 이 함수는 home-mixer(For You 피드를 조립하는 서비스)의 home-mixer/models/brand_safety.rs에 있다. 이번 커밋은 그 함수를 지우지 않은 채 옆에 compute_verdict_v2를 새로 만들었다. Grok 라벨 6종을 _V2 버전으로 옮겼고, 라벨 세트도 _V2 버전으로 넓혔다. 특정 V1 라벨을 강제로 걸러 내는 strip_v1_grok_written 헬퍼도 도입했다.

  • V2 판정에 쓰는 새 함수와 세 라벨 세트가 생겼다 확인 — home-mixer/models/brand_safety.rs.

    • V1과 비교하면 medium risk 라벨은 15개에서 16개로 늘었고, low risk 라벨은 3개 그대로 남았다.

    • GROK_NSFA는 GROK_NSFA_V2로 이름이 바뀌었고, GROK_NSFA_EXPANDED_V2가 새로 생겼다(medium 쪽에 하나가 늘었다).

    • GROK_NSFA_LIMITED는 GROK_NSFA_LIMITED_V2로 이름이 바뀌었다(low 쪽에서 하나가 옮겨졌다).

    • 라벨 세트 정의:

      pub(crate) const MEDIUM_RISK_LABELS_V2: &[SafetyLabelType] = &[
          SafetyLabelType::NSFW_HIGH_PRECISION,
          SafetyLabelType::NSFW_HIGH_RECALL,
          SafetyLabelType::NSFA_HIGH_PRECISION,
          SafetyLabelType::NSFA_KEYWORDS_HIGH_PRECISION,
          SafetyLabelType::GORE_AND_VIOLENCE_HIGH_PRECISION,
          SafetyLabelType::NSFW_REPORTED_HEURISTICS,
          SafetyLabelType::GORE_AND_VIOLENCE_REPORTED_HEURISTICS,
          SafetyLabelType::NSFW_CARD_IMAGE,
          SafetyLabelType::DO_NOT_AMPLIFY,
          SafetyLabelType::MALICIOUS_URL,
          SafetyLabelType::NSFA_COMMUNITY_NOTE,
          SafetyLabelType::PDNA,
          SafetyLabelType::EGREGIOUS_NSFW,
          SafetyLabelType::GROK_NSFA_V2,           // 신규
          SafetyLabelType::GROK_NSFA_EXPANDED_V2,  // 신규
          SafetyLabelType::NSFW_TEXT,
      ];
      
      pub(crate) const LOW_RISK_LABELS_V2: &[SafetyLabelType] = &[
          SafetyLabelType::NSFA_LIMITED_INVENTORY,
          SafetyLabelType::GROK_NSFA_LIMITED_V2,   // 신규
          SafetyLabelType::NSFA_HIGH_RECALL,
      ];
      
  • V2 판정은 V1 프롬프트가 이중으로 기록(dual write)한 라벨을 먼저 입력에서 지우고, 나머지 라벨만으로 판정한다 확인(같은 파일).

    • 지우는 조건은 둘이다.

      • 라벨이 NSFA_HIGH_PRECISION이거나 NSFA_LIMITED_INVENTORY다.
      • 그 라벨의 botmaker(X의 규칙 엔진) rule id가 PROMPT_OWNED_RULES = [1400, 1410, 1420, 1500, 1510, 1610, 1700]의 7개 가운데 하나다.
    • 두 조건을 모두 만족하면 V1 프롬프트가 dual-write한 라벨로 보고 입력 라벨 집합에서 제거한다.

    • 이 처리는 V2 로직이 V1 프롬프트가 만든 이중 기록을 무시하고 V2 라벨만으로 판단하게 만드는 마이그레이션 장치다.

    • 이 일을 맡는 핵심 로직은 strip_v1_grok_written이다:

      fn strip_v1_grok_written(labels: &HashMap<SafetyLabelType, SafetyLabel>)
          -> Cow<'_, HashMap<SafetyLabelType, SafetyLabel>>
      {
          const V1_DUAL_WRITTEN: &[SafetyLabelType] = &[
              SafetyLabelType::NSFA_HIGH_PRECISION,
              SafetyLabelType::NSFA_LIMITED_INVENTORY,
          ];
          const PROMPT_OWNED_RULES: &[i64] = &[1400, 1410, 1420, 1500, 1510, 1610, 1700];
          let is_v1_grox_written = |label_type: &SafetyLabelType, label: &SafetyLabel| {
              V1_DUAL_WRITTEN.contains(label_type)
                  && botmaker_rule_id_from(label)
                      .is_some_and(|rule_id| PROMPT_OWNED_RULES.contains(&rule_id))
          };
          if !labels.iter().any(|(t, l)| is_v1_grox_written(t, l)) {
              return Cow::Borrowed(labels);
          }
          Cow::Owned(
              labels.iter()
                  .filter(|&(t, l)| !is_v1_grox_written(t, l))
                  .map(|(t, l)| (*t, l.clone()))
                  .collect(),
          )
      }
      
  • V2 판정 함수의 판정 순서는 V1과 같다. 달라진 점은 참조하는 라벨 이름과 앞에 들어간 전처리, 두 가지다 확인(같은 파일).

    • 순서는 medium 캐스케이드 → Grok 채점 여부 → PTOS(정책을 기준으로 삼는 위반 카테고리 판정) 심사 → low → 그 외에는 safe다.

    • 참조하는 라벨 이름이 GROK_SFA에서 GROK_SFA_V2로, GROK_NSFA_LIMITED에서 GROK_NSFA_LIMITED_V2로 바뀌었다.

    • V1의 이중 기록을 제거하는 전처리가 맨 앞에 들어갔다.

    • compute_verdict_v2의 판정 순서를 보여 주는 코드:

      pub(crate) fn compute_verdict_v2(labels: ..., tweet_id: u64) -> BrandSafetyVerdict {
          let labels = strip_v1_grok_written(labels);
          if MEDIUM_RISK_LABELS_V2.iter().any(|l| labels.contains_key(l)) {
              return BrandSafetyVerdict::MediumRisk;
          }
          let scored_by_grok = labels.contains_key(&SafetyLabelType::GROK_SFA_V2)
              || labels.contains_key(&SafetyLabelType::GROK_NSFA_LIMITED_V2);
          if !scored_by_grok {
              return BrandSafetyVerdict::MediumRisk;
          }
          if tweet_id >= PTOS_CUTOFF_TWEET_ID && !labels.contains_key(&SafetyLabelType::PTOS_REVIEWED) {
              return BrandSafetyVerdict::MediumRisk;
          }
          if LOW_RISK_LABELS_V2.iter().any(|l| labels.contains_key(l)) {
              return BrandSafetyVerdict::LowRisk;
          }
          BrandSafetyVerdict::Safe
      }
      
  • X는 V1과 V2가 같은 판정을 내려야 한다는 점을 테스트로 명시한다 확인(같은 파일).

    • 같은 파일의 v2_mirrors_v1_across_tier_matrix가 6가지 라벨 조합 × 두 tweet_id 시점(PTOS_CUTOFF 전과 후)에서 두 판정이 같은지 assert한다.

    • 마이그레이션이 진행되는 동안 두 판정이 어긋나지 않아야 광고 인접성이 바뀌지 않기 때문에, 이 테스트가 안전장치 역할을 한다.

    • 테스트 코드:

      #[test]
      fn v2_mirrors_v1_across_tier_matrix() {
          fn to_v2(v1_set: &[SafetyLabelType]) -> Vec<SafetyLabelType> { /* V1 → V2 라벨 이름 변환 */ }
          let matrix: &[&[SafetyLabelType]] = &[
              &[],
              &[SafetyLabelType::GROK_SFA],
              &[SafetyLabelType::GROK_SFA, SafetyLabelType::PTOS_REVIEWED],
              // ... 6개 시나리오
          ];
          for v1_set in matrix {
              let v2_set = to_v2(v1_set);
              for tweet_id in [PRE_CUTOFF_ID, POST_CUTOFF_ID] {
                  assert_eq!(
                      compute_verdict(&labels_with(v1_set), tweet_id),
                      compute_verdict_v2(&labels_with(&v2_set), tweet_id),
                  );
              }
          }
      }
      
  • 이 변경은 라벨 스키마 마이그레이션을 실제 프로덕션에서 어떻게 다루는지 보여 주는 구체적인 사례이고, 정본 주의 목록의 "죽은 파일" 판별법과도 이어진다.

    • V1과 V2가 둘 다 살아 있고, worst_verdict가 두 결과를 결합하는지 여부에 따라 광고 인접성 판정이 달라진다.
  • Grok의 안전 라벨 자체가 버전으로 관리되고, 그 마이그레이션은 코드 안에서 dual-write를 감지하는 로직으로 구현되어 있다.

    • 정본은 3세대부터 4세대까지 광고 브랜드 안전을 "손으로 짠 규칙 캐스케이드"로 반영해 왔다.
    • 이 내용도 "죽은 파일" 판별법과 이어진다. V1과 V2가 둘 다 export되기 때문에, 어느 쪽이 실제로 호출되는지는 상위 하이드레이터(ads_brand_safety_vf_hydrator.rs)에서 확인해야 한다. 확인 이번 커밋은 그 하이드레이터도 함께 수정했는데(+13/−7), 상위 호출 경로가 V1에서 V2로 전환되는 중이라는 신호로 볼 수 있다.
  • botmaker 프롬프트가 소유한다고 표시된 rule id 7개가 실제로 어느 규칙인지는 공개되지 않았다.

    • PROMPT_OWNED_RULES = [1400, 1410, 1420, 1500, 1510, 1610, 1700]은 이름부터 botmaker 프롬프트가 소유한다고 표시되어 있다.
    • 하지만 4세대에서 공개된 botmaker-rules/의 어느 규칙과 정확히 대응하는지는 공개되지 않았다. rule id를 규칙 파일에 연결하는 매핑 문서가 없기 때문이다.
    • 이 매핑을 정본의 심층 분석 필요 목록에 추가한다.

새로 알게 된 것B. 성인 컨텐츠 판정이 엇갈리면 Grok 4.5 심판이 다시 판단하고, hard를 soft로 낮출 수 있다

성인 컨텐츠 판정에, 판정이 서로 엇갈릴 때를 대비하는 심판(judge) 계층이 추가됐다. 4세대에서 처음 공개된 grox(Grok 기반 nearline 주석 파이프라인)의 안전 파이프라인은 두 판정을 병렬로 실행하고, 두 결과가 다를 때는 로그만 남겼다. 하나는 TaskSafetyPtosSafemodelSexNudity(자체 비전 모델을 사용하는 sex/nudity 판정)이고, 다른 하나는 PtOS(정책을 기준으로 삼는 위반 카테고리 판정)다.

이번 커밋은 이 로직을 두 파일로 나눴다. safemodel은 판정만 맡는다(_compare_outcome을 제거했다). 새 파일 task_safety_ptos_adult_content_cross_validation.py는 safemodel과 PtOS의 판정이 어긋날 때 Grok 4.5 심판을 불러 다시 판단을 받는다. 어긋나는 경우는 safemodel이 positive로 판정했는데 PtOS 정책이 hard 위반을 잡아내지 못한 경우와 그 반대인 경우다. 심판이 "hard가 아니다"라고 답하면 최종 결정은 AdultContentSexualSoft로 강등되고, safemodel이 남긴 positive 표시도 지워진다. 판정 계층은 safemodel, PtOS, Grok 4.5 심판의 세 층으로 늘었다.

  1. 먼저 두 판정을 비교해 불일치를 찾는다 확인(같은 파일).
    • 새 enum CompareOutcome이 BOTH_POSITIVE·BOTH_NEGATIVE·SAFEMODEL_ONLY_POSITIVE·PTOS_ONLY_POSITIVE 4가지를 구분한다.
    • is_disagreement는 SAFEMODEL_ONLY이거나 PTOS_ONLY일 때 true가 된다.
    • 이 로직은 4세대에서 safemodel_sex_nudity.py 안에 있었는데, 이번 커밋이 떼어 내 새 파일로 옮겼다.
  2. deluxe 플로우에서만 동작하고, 이미 NSFW로 플래그된 포스트는 건너뛴다 확인(같은 파일).
    • is_deluxe = ctx.payload.task_type == SAFETY_PTOS_DELUXE이므로 deluxe 플로우에서만 동작한다. 일반 플로우는 예전처럼 로그만 남긴다.
    • 플래그 여부는 _post_is_already_flagged_nsfw가 StratoSafetyPostAnnotationsResultDirectMh를 조회해서 확인한다.
  3. 불일치가 있으면 Grok 4.5 심판을 호출한다 확인(같은 파일).
    • 새 클래스 SafetyPtosAdultContentCrossValidationJudge가 ModelName.EAPI_GROK_4_5_X_ALGO 모델을 호출한다.
    • system 메시지에는 adult_content_policy_prompt()를 넣는다.
    • user 메시지에는 PostRenderer.render(post, include_reply_to=True)와 UserRenderer.render(post.user)를 넣는다.
    • <json>...</json> 형식의 응답을 파싱해서 SafetyPolicy 오브젝트를 얻는다.
    • 이 호출에는 _EAPI_4_5_X_ALGO_BREAKER_CONFIG라는 별도의 서킷브레이커가 붙어 있다. 실패율 50%, 창 600초, 회복 600초, half-open 5회로 설정되어 있다.
  4. 심판이 "hard가 아니다"라고 답하면 판정을 soft로 낮춘다 확인(같은 파일).
    • 심판이 AdultContentSexualHard라고 답하면 원래 판정을 그대로 유지한다.
    • 그 밖의 경우에는 다음 세 가지를 한다.
      • 모든 AdultContent 위반의 safetyPolicy.policyType을 AdultContentSexualSoft로 강등한다.
      • 강등된 위반에 reason = "Grok 4.5 Cross Validation disagreed"를 표기한다.
      • safemodel_sex_nudity.positive를 false로 지운다.
    • 원래 판정 이유가 무엇이었든, 최종 감사 로그에는 "Grok 4.5 심판이 반대해서 강등되었다"는 사실이 명시적인 텍스트로 기록된다.
  • 같은 커밋에서 task_safety_ptos_safemodel_sex_nudity.py도 함께 정리됐다 확인 — grox/flows/ptos/task_safety_ptos_safemodel_sex_nudity.py.

    • _compare_outcome과 비교 로그, CompareOutcome enum 정의를 지웠다. cross_validation 파일로 옮긴 것이다.
    • deluxe 플로우에서 이미 NSFW로 플래그된 포스트이면 판정 자체를 건너뛰는 사전 게이트를 추가했다.
    • 판정이 끝나는 시점에 safemodel_sex_nudity.scored = True를 명시적으로 설정한다. cross_validation은 이 플래그를 보고 실행 여부를 판단한다.
    • 즉 두 파일은 서로 의존한다. safemodel이 먼저 실행되면서 scored=True를 남기면, cross_validation이 그 플래그를 보고 실행된다. 이어서 판정 결과를 비교하고, 필요하면 Grok 4.5에 재판단을 요청한다.
  • Grok 4.5 심판 호출은 시스템 프롬프트에서 사고 과정을 감추라는 제약을 벗겨 낸다 확인 — grox/flows/ptos/classifier.py:597-608.

    • _strip_thinking_restrictions()가 시스템 프롬프트를 감싼다.

    • 원래 프롬프트에는 "이런 답변은 사고 과정을 노출하지 마라"에 해당하는 제약이 있다. 심판 맥락에서는 그 제약을 벗겨 내 사고 과정을 볼 수 있게 만든다.

    • 프롬프트를 구성하는 코드:

      convo.messages.append(Message(
          role=Role.SYSTEM,
          content=[_strip_thinking_restrictions(adult_content_policy_prompt())],
      ))
      user_msg = Message(role=Role.USER, content=[])
      user_msg.content.extend(UserRenderer.render(post.user))
      user_msg.content.extend(PostRenderer.render(post, include_reply_to=True))
      user_msg.content.append(
          f"\n\nAnalyze the post {post.id} for the specific safety policy violation category: {SafetyPolicyCategory.AdultContent.value}"
      )
      user_msg.content.append(
          f"\n\nProvide the requested JSON object for the specific safety policy type.{THINKING_CONTROL_START}"
      )
      
  • 답글이면 부모 포스트도 컨텍스트에 함께 넣는다 확인(같은 파일).

    • include_reply_to=True로 둔 결과다.
    • 그 답글이 어떤 포스트에 대한 반응인지가 성인 컨텐츠 판정에 영향을 준다는 뜻이다.
  • 심판은 사고 과정을 자유 텍스트로 남기고, 최종 판정만 JSON으로 반환한다 확인(같은 파일).

    • 응답은 re.compile(r"(.*)<json>(.*)</json>", re.DOTALL)로 파싱한다.
    • 응답이 자유 텍스트와 <json>...</json> 태그 안의 JSON으로 나뉘어 나오는 형식이다.
  • 이번 변경은 판정 자체를 여러 모델의 앙상블로 재구성하는 방향을 보여 준다.

    • 정본은 지금까지 판정 구조를 "판정은 단일 (safemodel 또는 PtOS 또는 Grok) → visibility rules → 필터/광고 인접성"으로 반영해 왔다.
    • 세 층은 서로 다른 모델로 이루어진다. safemodel은 자체 비전 모델, PtOS는 정책 판정 모델, Grok 4.5는 LLM 심판이다.
    • 판정이 어긋나는 상황을 이용해 최종 판정을 자동으로 조정한다는 점이 특징이다. 4세대의 "labeling 파이프라인 완전 공개"와 방향이 같다.
  • 판정이 강등되는 방향(hard → soft)만 있고 격상되는 방향은 없다는 점도 중요한 관찰이다.

    • 원래 판정이 놓친 hard 케이스를 심판이 잡아내 격상하는 로직은 이 커밋에 들어 있지 않다.
    • 즉 이 심판 계층은 과도한 게이팅을 완화하는 방향으로만 작동하도록 설계됐다.
    • 이 설계는 성인 컨텐츠 게이팅의 정책 방향이 false positive를 줄이는 쪽이지 false negative를 줄이는 쪽이 아니라는 사실을 코드에서 확인할 수 있게 해 준다.
  • 앞으로 지켜볼 점은 두 가지다.

    • 이 심판 계층이 다른 카테고리(폭력·혐오·스팸)로도 확장되는지 확인해야 한다.
    • Grok 4.5를 사용하는 비용(추론 코스트)을 어느 정도까지 감당할 수 있는지 확인해야 한다.

새로 알게 된 것C. favorite 1개 이상이던 인덱싱 조건이 사라져 fav 이벤트 후보는 카운트와 상관없이 인덱스로 간다

Phoenix retrieval의 인덱싱 게이트가 사실상 제거됐다. phoenix-rankall-strato(어떤 인덱스에 포스트가 속하는지 결정하는 이벤트 층)의 build1FavIndex는 이름 그대로 "1개 이상 favorite을 받은 포스트를 index에 넣는" 함수였다. buildMMEmbMetadataDump(멀티모달 임베딩 메타데이터를 Kafka로 덤프하는 함수)도 같은 favoriteCount >= 1 게이트를 통과해야 실행됐다.

이번 커밋은 두 게이트를 모두 없앴다. 그래서 fav 이벤트로 들어온 답글·리포스트가 아닌 VF 통과 후보는 fav 수와 상관없이 "1fav" 인덱스와 멀티모달 임베딩 덤프로 흘러 들어간다. 다만 "1fav"라는 이름은 그대로 남았고, fav가 0인 상태로 도달한 경우는 1fav_uec_count_race라는 새 카운터로 계측만 한다.

  • 인덱싱 이벤트를 만드는 phoenixRankAllCandidateProcessor.strato에서 두 함수가 바뀌었다 확인 — phoenix-rankall-strato/columns/phoenix_rank_all/phoenixRankAllCandidateProcessor.strato.

  • 두 함수를 부르는 범위는 그대로다 확인 — 같은 파일 L421-458.

    • 디스패치는 eventSource == Fav일 때만 두 함수를 부르고, 그 전에 커뮤니티 포스트·답글·리포스트를 건너뛴다.
    • VF가 드롭하는 포스트(shouldDropPostByVF)와 성인 포스트는 build1FavIndex에 오지 못한다. 없어진 것은 함수 안의 favoriteCount 게이트뿐이다.
    • 다만 몰입형 영상이 있는 성인 포스트의 Fav 이벤트는 따로 buildMMEmbMetadataDump를 부른다(같은 파일 L438-442).
  • 1fav 인덱스를 만드는 build1FavIndex는 이제 fav 카운트와 상관없이 매번 인덱싱 이벤트를 발행하고, fav가 0인 경우만 카운터로 계측한다 확인(같은 파일).

    • 계측에 쓰는 카운터는 1fav_uec_count_race다.

    • 변경 전:

      def build1FavIndex(candidate: PhoenixRankAllCandidate, stats: Stats.StatsReceiver): Unit = {
        candidate.engagementCount match {
          case Some(ec) =>
            if(ec.favoriteCount.getOrElse(0) >= 1) {
              val object = { postId = candidate.postId, authorId = candidate.authorId, indexName = "1fav" }
              #<hydra/kafka/indexingEventKafkaTopic>.insert((), object)
              stats.counter("1fav_indexing_event_created").incr(1)
            }
          case None => ()
        }
      }
      
    • 변경 후:

      def build1FavIndex(candidate: PhoenixRankAllCandidate, stats: Stats.StatsReceiver): Unit = {
        if (candidate.engagementCount.flatMap { ec => ec.favoriteCount }.getOrElse(0) == 0) {
          stats.counter("1fav_uec_count_race").incr(1)
        }
        val object = { postId = candidate.postId, authorId = candidate.authorId, indexName = "1fav" }
        #<hydra/kafka/indexingEventKafkaTopic>.insert((), object)
        stats.counter("1fav_indexing_event_created").incr(1)
      }
      
  • 카운터 이름의 "race"는 uec(user engagement counts) 저장소와 후보가 도착하는 시점이 어긋나서 fav가 아직 반영되지 않았을 가능성을 가리키는 것으로 보인다 추정.

  • 별도 함수인 buildMMEmbMetadataDump도 똑같이 favoriteCount >= 1 게이트를 제거했기 때문에, 이제 같은 범위의 후보에 대해 fav 수와 상관없이 실행된다 확인(같은 파일).

    • 이 부분은 멀티모달 임베딩 v3·v5_1·v6_dev 세 버전을 조회해서 Kafka로 덤프한다.
    • 변경 전에는 if(ec.favoriteCount.getOrElse(0) >= 1) { ... 멀티모달 임베딩 fetch + Kafka 덤프 ... }였다.
    • 변경 후에는 게이트가 없어서 fav 수와 상관없이 멀티모달 임베딩 3개 버전을 조회하고 Kafka로 덤프한다.
  • 이 변경은 정본이 3세대부터 4세대까지 "인덱스는 인기도 신호를 담는다"고 반영해 온 이해와 정면으로 어긋난다.

    • fav 이벤트로 들어왔지만 카운트가 아직 0인 포스트까지 1fav 인덱스에 들어간다는 뜻이다.
    • 정본이 3세대부터 4세대까지 관찰해 온 "phoenix-rankall이 인기 신호로 인덱스를 갱신한다"는 이해는 이제 정확하지 않다.
    • 오늘 이후로 "1fav"라는 인덱스 이름은 하위 호환을 위해 남겨 둔 라벨일 뿐이고, 실제 인덱스는 fav 이벤트로 유입된 후보를 카운트와 상관없이 담는다.
    • 이 관찰은 정본 주의 목록에 "인덱스 이름이 필터 조건을 뜻하지는 않는다"를 추가할 만한 근거가 된다.
  • 멀티모달 임베딩 덤프의 게이트를 없앤 것은 특히 눈여겨볼 만하다.

    • v3·v5_1·v6_dev 세 임베딩을 인기 없는 포스트까지 전부 조회해서 Kafka로 덤프하는 것은 retrieval 시그널을 마련하기 위한 인벤토리 준비 비용을 크게 늘리는 선택이다.
    • 이는 정본이 서술해 온 "인기도가 있어야 후보로 진입 가능"과 반대되는 방향이며, Phoenix retrieval이 롱테일 포스트에도 임베딩 기반 매칭을 시도할 수 있도록 인프라를 확장하는 작업으로 보인다 추정.
  • 앞으로 나올 커밋에서 드러날 지표는 두 가지다.

    • 1fav_uec_count_race 카운터의 실제 관측치가 어느 정도 비율로 나오는지 본다.
    • 인덱스 커버리지 확대가 어떤 param 게이트와 짝을 이루어 활성화되는지 본다. 예를 들면 Phoenix retrieval의 top-k나 SID(semantic ID, 아이템을 계층 코드로 표현한 토큰 열) window다.

정본 반영 services/x.md의 7개 절을 갱신했다.

  • "한 줄 요약" 절에 4세대+2 후속 공개(brand safety V2, Grok 4.5 cross validation, 인덱싱 게이트 제거) 요약을 추가했다.
  • "공개 소스 4세대" 세대 테이블에 4세대+2 행을 새로 만들었다(커밋 b089ce6, +751/−162, 파일 34개).
  • "광고" 절에 brand safety V2 라벨 세트(medium 16개와 low 3개), strip_v1_grok_written 마이그레이션 헬퍼, v2_mirrors_v1_across_tier_matrix 테스트를 추가했다.
  • "Grox → PtOS Adult Content Cross-Validation (4세대+2 신규)" 절을 새로 만들어 4단계 흐름과 심판 프롬프트 세부, 강등 방향의 특징을 정리했다.
  • "Phoenix 학습·인덱스" 절에 phoenix-rankall-strato의 인덱싱 게이트 제거 관찰을 추가했다. 1fav_uec_count_race 카운터 신설과 멀티모달 임베딩 덤프 게이트 제거가 그 내용이다.
  • 열린 질문 절에 "4세대+2 공개로 해소된 것 (2026-08-19)" 하위 절을 새로 만들고, PROMPT_OWNED_RULES와 botmaker rule의 매핑을 심층 분석 필요 목록에 추가했다.
  • 출처 절에 4건을 추가했다. b089ce6 커밋, brand_safety.rs, adult_content_cross_validation.py, phoenixRankAllCandidateProcessor.strato다.

의미 4세대(2026-08-13) → 4세대+1(2026-08-14) → 4세대+2(2026-08-17)로 이어지는 사흘 간격의 세 번째 공개를 통해 X의 공개 리듬이 자리를 잡은 것으로 보인다. 특히 각 공개의 성격이 서로 다르다는 점이 중요하다. 광고 브랜드 안전과 성인 컨텐츠 판정이 한 커밋에서 각각 다른 방향으로 정교화됐다는 점도 이번 공개의 특징이다.

  • 4세대는 파라미터와 labeling 파이프라인을 공개했다(botmaker 규칙 일부 제외). 구조를 여는 대형 공개다.
  • 4세대+1은 가중치 해석과 규제 대응, A/B 연결, 모델 입력 확장을 담았다. 다섯 방향으로 넓힌 공개다.
  • 4세대+2는 광고 브랜드 안전 V2 마이그레이션과 Grok 4.5 심판 계층, retrieval 인덱스 게이트 제거를 담았다. 판정 계층을 정교화한 공개다.
    • V2 마이그레이션은 라벨 스키마를 정정하는 작업이고, cross-validation은 판정 계층을 확장하는 작업이다.
  • X가 4세대의 공개 리듬을 "대형 공개 → 후속 하루짜리 정리 → 며칠 뒤 계층별 정교화"로 잡아 가고 있다고 볼 근거가 하나 더 늘었다 관찰 2026-08-19.
  • 정본이 이 흐름에서 얻을 수 있는 것은 두 가지다.
    • X의 커밋 로그를 매일 추적하는 작업의 가치가 계속 커진다. 커밋마다 서로 다른 축을 건드리기 때문이다.
    • X의 open-source 관행이 특정 방향(규제 대응, 안전 판정 계층, retrieval 커버리지)으로 이동한다는 초기 신호가 세 번의 공개에 걸쳐 쌓이고 있다.

심층 분석이 필요하지만 오늘 처리하지 않은 항목은 다섯 가지다.

  • V1에서 V2로 가는 라벨 마이그레이션이 완료되는 시점을 본다. V1 함수를 삭제하는 커밋이 나오는지, 아니면 dual-write를 영구히 유지하는지 확인해야 한다.
  • PROMPT_OWNED_RULES의 botmaker rule id 7개(1400/1410/1420/1500/1510/1610/1700)가 botmaker-rules/의 실제 규칙 파일 가운데 무엇과 대응하는지 확인해야 한다.
  • Grok 4.5 심판 계층이 다른 카테고리(폭력·혐오·스팸)로 확장되는지 확인해야 한다.
  • 1fav_uec_count_race 카운터의 실제 관측 비율을 본다. 외부에서 직접 관측할 수는 없지만, 후속 커밋에서 임계값을 조정하는 것을 보고 간접적으로 관찰할 수 있다.
  • 멀티모달 임베딩 v3/v5_1/v6_dev 세 버전을 소비하는 쪽이 어디인지 확인해야 한다. 홈 랭킹이 아니라는 사실은 정본에 이미 반영했지만, 어디로 가는지는 아직 확인하지 못했다.

2낡은 콘텐츠를 두 종류로 나눠 거르는 필터가 2년간 운영됐다 — 레이더(Google Discover)

자료 Decomposing Staleness in Recommender Systems: A Dual-Filter Framework for Supersession and Decay — arXiv:2608.15780 — 2026-08-16 제출, 논문. 저자 6명 전원이 Google LLC 소속이다. HTML 판에서 저자 affiliation 표기를 확인했고, 이메일 도메인이 모두 @google.com이다. CIKM Applied Research Track 2026에 accepted됐고, DOI는 10.1145/3799682.3840082다.

배경 Google Discover는 Android 홈스크린과 Google 앱에서 제공되는 개인화 피드 지면이며, DAU는 수억 명, MAU는 수십억 명 규모다 확인(논문 abstract). 정본은 지금까지 Google Discover를 추적 서비스로 등록하지 않았는데, Google이 Discover의 알고리즘과 랭킹, retrieval 계층을 전부 공개한 적이 없었기 때문이다. 이번 논문은 그 지면의 staleness 필터(낡은 후보를 거르는 필터)만 다루므로 전면적인 아키텍처 공개가 아니라 단일 컴포넌트 공개에 그친다. 그래도 프로덕션에 2년 동안 배포했고 유저 피드백 지표가 54.9% 개선되었다는 실제 A/B 결과 진술이 있어서, 레이더 수용 기준(플랫폼 기업 저자, 프로덕션 배포, 대규모 스케일)을 충족한다.

새로 알게 된 것A. 논문은 '낡음'을 두 메커니즘으로 나누고, 각각을 독립된 모델로 예측한다

이 논문이 정의한 문제는 콘텐츠 피드에서 "이 후보가 낡았다"고 판정하는 서로 다른 두 메커니즘을 분리하고, 각각을 독립된 모델로 예측하는 것이다.

  • Supersession(초월/대체)은 새 아이템이 도착해서 이전 아이템의 정보 가치가 무효화되는 경우다.
    • 예를 들어 스포츠 선수의 팀 이적 소식이 발표되면 그 이전의 이적 협상 기사들이 자동으로 낡는다.
    • 판정 근거는 시간 자체가 아니라 후속 아이템이 존재하는지 여부다.
  • Relevance decay(관련성 감쇠)는 후속 아이템이 없어도 아이템 자체의 정보 가치가 시간에 따라 자연히 줄어드는 경우다.
    • 예를 들어 "오늘 밤 유성우" 안내는 그날 저녁에는 유용하지만 다음날부터는 쓸모가 없다.
    • 판정 근거는 시간에 따른 곡선이다.
  • 기존의 관행(age cutoff, engagement heuristic)은 이 두 메커니즘을 구분하지 못한다. 그래서 두 가지 문제가 생긴다.
    • age cutoff는 관련성 감쇠를 크게 과대평가하거나 과소평가한다.
    • engagement heuristic은 lagging signal이기 때문에 낡은 컨텐츠가 이미 노출된 뒤에야 반응한다.

새로 알게 된 것B. 두 필터를 OR로 묶어 랭킹 전 단계에서 후보를 지운다

  1. Relational Staleness Filter는 supersession을 판정하며, pairwise classification framework를 사용한다 확인(같은 절).
    • 후보 아이템 d_t와 그 뒤에 도착한 topic-related 아이템 d'의 쌍을 놓고, 학습된 함수 f(d_t, d')가 "d'가 d_t를 낡게 만드는가"를 이진으로 판정한다.
    • 이 판정 결과들의 disjunction (∃ d' > t: f(d_t, d') = 1)이 supersession indicator가 된다.
    • 훈련 데이터는 규모 때문에 수동 어노테이션이 불가능하므로 LLM 파이프라인으로 합성해서 만든다.
    • 서빙에는 지식 증류(knowledge distillation)를 거친 학생 모델을 배포한다.
  2. Predicted Traffic Ratio (PTR) Filter는 relevance decay를 판정하는 multi-horizon 회귀 모델이다 확인(같은 절).
    • 아이템의 멀티모달 콘텐츠(텍스트·이미지·비디오)를 입력으로 받아서 미래의 여러 horizon에서 traffic ratio(정규화된 방문 트래픽)를 예측한다.
    • 학습 라벨로는 아이템의 lifetime visit traffic, 즉 수명 전체에 걸친 방문량을 사용한다.
    • 서빙 시점의 traffic ratio가 임계값보다 낮으면 decay indicator가 1이 된다.
  3. 두 필터는 disjunction(OR)으로 결합되어 upstream(ranking 이전의 후보 프루닝 단계)에 배치된다 확인(같은 절).
    • supersession과 decay 가운데 하나라도 판정되면 후보에서 제거한다.
    • ranking으로 넘어가는 후보 수가 줄어들기 때문에 downstream serving cost도 함께 감소한다.

새로 알게 된 것C. 2년 운영 뒤 사용자가 직접 낸 staleness 신고가 54.9% 줄었다

  • 2년 동안 프로덕션에 배포한(initial → 확장 → 안정화) 결과 user-filed staleness reports가 54.9% 감소했다 확인 — arXiv 2608.15780v1, 4.3절 Online Deployment.

    • in-product user feedback 기준이며, pre-deployment baseline과 비교한 값이다.
    • 신고를 유형별로 나누면 supersession 신고는 64.0%, decay 신고는 34.4% 줄었다.
    • 논문은 유형별 감소가 주로 대응하는 필터 덕분이라고 쓰지만, 필터 하나만 켠 단독 기여를 잰 값은 아니다.
  • engagement neutral 제약 아래에서 튜닝했다 확인(4.2절 Online A/B Testing).

    • 무리하게 공격적으로 잘라 내면 staleness 지표는 더 좋아지지만 engagement를 떨어뜨리기 때문에 그런 방식은 배제했다는 뜻이다.

    "Configurations are tuned to be engagement-neutral; trivially aggressive cuts could reduce staleness much further but at the cost of substantially shrinking the candidate set and degrading user engagement, which we explicitly rule out." — 같은 논문, 4.2절

  • 이 제약 아래에서 각 필터는 단독으로도 통계적으로 유의한 음의 Δstale을 낸다 확인(같은 절).

    • Δstale은 실험군이 노출을 늘린 아이템과 줄인 아이템의 stale 비율 차이이고, 음수면 실험군이 staleness를 줄였다는 뜻이다.
    • Table 3의 Δstale은 relational −4.19%, intrinsic −3.10%, SDF 전체 −6.91%다.
    • engagement 개선(feed positive engagement +0.26±0.18%)은 필터별 값이 아니라, 두 필터를 함께 끈 두 달짜리 joint-holdback 실험에서 잰 값이다.
  • online staleness는 A/B arm의 통제군과 처리군에서 매주 샘플링해서 4주 연속으로 관찰했다(view delta 크기로 가중치를 준다) 확인(같은 절).

정본 반영 radar.md의 "이번 달 신규" 절에 SDF 항목(Google Discover — 2년 프로덕션 staleness 필터, supersession + decay 이중 학습)을 새로 넣었다. 상태는 "관찰"로 적었다.

의미 이 논문은 정본의 세 방향에 영향을 줄 가능성이 있다.

  1. X의 stale post 처리와 비교하는 축이 된다.
    • 4세대+1(2026-08-14)에서 관찰한 X의 IS_STALE_POST14D bool feature(daily/2026-08-16.md)는 "14일이 지나면 engagement count를 0으로 stamp하고 bool을 셋한다"는 규칙 기반 처리다.
    • 반면 SDF는 supersession을 학습 모델로 예측하고 decay를 lifetime traffic 회귀로 예측하는, 완전히 학습에 기반한 접근이다.
    • 두 접근은 정본의 관점에서 staleness에 대한 서로 다른 두 정의를 반영한다.
    • X는 노출 감소가 count zero로 이어지는 자기강화 루프를 완화하려고 랭커에게 명시적인 라벨을 주는 쪽이다. Google Discover는 candidate pool에서 아예 제거하는 쪽, 즉 retrieval 이전에 프루닝하는 쪽이다.
    • 두 처리가 놓인 위치가 근본적으로 다르다.
  2. 피드 지면의 staleness를 topic으로 승격할 후보가 된다.
    • 현재 정본의 topic 목록에는 staleness가 없다.
    • Netflix Foundation Model이나 Meta ConnectionMind가 stale item을 어떻게 처리하는지는 아직 확인하지 못했다.
    • 두 번째 회사에서 비슷한 진술이 나오면 topics/에 절을 새로 만드는 것을 검토한다.
  3. Google Discover는 지금은 추적 서비스로 등록하지 않는다.
    • 이 논문은 단일 컴포넌트인 staleness 필터만 공개했고, retrieval과 ranking, 다양성을 비롯한 나머지 알고리즘 스택은 여전히 공개되지 않았다.
    • 그 상태에서 정본 파일을 만드는 것은 이 저장소의 "서비스 정본은 압축된 결론"이라는 원칙과 맞지 않는다.
    • 레이더에 올려 두었다가 두 번째 컴포넌트가 공개되면 다시 검토한다.

다음에 볼 것