From c82afe4a2326067c679cf0789e6fb8c0ec48a490 Mon Sep 17 00:00:00 2001 From: Jon Bailey <297513015+Pitchfork-and-Torch@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:06:52 -0400 Subject: [PATCH] Stop Phoenix authorIsNsfwSeq from skipping retweets as_tweet_info zeroed safety_label_mask on every retweet, so Phoenix hashed the origin author with authorIsNsfwSeq=0. Gizmoduck already fetched the origin user but wrote the retweeter's NSFW bit. Stamp nsfw_author_phoenix from the origin on retweets and drop the retweet skip in as_tweet_info. Filter/ads poster bits are unchanged. --- .../candidate_hydrators/gizmoduck_hydrator.rs | 95 ++++++++++++++++--- home-mixer/models/candidate.rs | 56 ++++++++++- 2 files changed, 137 insertions(+), 14 deletions(-) diff --git a/home-mixer/candidate_hydrators/gizmoduck_hydrator.rs b/home-mixer/candidate_hydrators/gizmoduck_hydrator.rs index c2eeb9e9..7b765e47 100644 --- a/home-mixer/candidate_hydrators/gizmoduck_hydrator.rs +++ b/home-mixer/candidate_hydrators/gizmoduck_hydrator.rs @@ -8,6 +8,29 @@ use xai_candidate_pipeline::component_library::utils::{default_quick_cache, Quic use xai_candidate_pipeline::hydrator::{CacheStore, CachedHydrator}; use xai_x_thrift::user_labels::LabelValue; +/// Phoenix hashes the origin author. `nsfw_author_phoenix` must be that +/// account's bit, not the retweeter's. Filter/ads bits stay on the poster. +pub(crate) fn phoenix_author_nsfw_bit( + is_retweet: bool, + poster_nsfw: Option, + origin_nsfw: Option, +) -> Option { + if is_retweet { + origin_nsfw + } else { + poster_nsfw + } +} + +fn phoenix_nsfw_from_safety( + nsfw_user: bool, + nsfw_admin: bool, + high_precision: bool, + possibly: bool, +) -> bool { + nsfw_user || nsfw_admin || high_precision || possibly +} + pub struct GizmoduckCandidateHydrator { pub gizmoduck_client: Arc, pub cache: QuickCache, @@ -122,6 +145,7 @@ impl CachedHydrator for GizmoduckCandidateHydra retweet_profile.map(|x| x.screen_name.clone()); let author = user.and_then(|u| u.user.as_ref()); + let origin_author = retweet_user.and_then(|u| u.user.as_ref()); let nsfw_author: Option = author.map(|u| { u.safety.nsfw_admin || u.safety.nsfw_user @@ -136,17 +160,27 @@ impl CachedHydrator for GizmoduckCandidateHydra label.label_value == LabelValue::POSSIBLY_NSFW_ACCOUNT.0 }) }); - let nsfw_author_phoenix: Option = author.map(|u| { - u.safety.nsfw_user - || u.safety.nsfw_admin - || u.labels.labels.iter().any(|l| { - matches!( - LabelValue(l.label_value), - LabelValue::NSFW_HIGH_PRECISION - | LabelValue::POSSIBLY_NSFW_ACCOUNT - ) - }) - }); + let phoenix_nsfw = |u: &_| { + phoenix_nsfw_from_safety( + u.safety.nsfw_user, + u.safety.nsfw_admin, + u.labels + .labels + .iter() + .any(|l| l.label_value == LabelValue::NSFW_HIGH_PRECISION.0), + u.labels + .labels + .iter() + .any(|l| l.label_value == LabelValue::POSSIBLY_NSFW_ACCOUNT.0), + ) + }; + let poster_phoenix = author.map(phoenix_nsfw); + let origin_phoenix = origin_author.map(phoenix_nsfw); + let nsfw_author_phoenix = phoenix_author_nsfw_bit( + candidate.retweeted_user_id.is_some(), + poster_phoenix, + origin_phoenix, + ); Ok(PostCandidate { author_followers_count, @@ -191,3 +225,42 @@ pub struct GizmoduckCacheValue { pub nsfw_author_ads: Option, pub nsfw_author_phoenix: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phoenix_bit_uses_poster_on_originals() { + assert_eq!( + phoenix_author_nsfw_bit(false, Some(true), Some(false)), + Some(true) + ); + assert_eq!( + phoenix_author_nsfw_bit(false, Some(false), Some(true)), + Some(false) + ); + } + + #[test] + fn phoenix_bit_uses_origin_on_retweets() { + assert_eq!( + phoenix_author_nsfw_bit(true, Some(false), Some(true)), + Some(true) + ); + assert_eq!( + phoenix_author_nsfw_bit(true, Some(true), Some(false)), + Some(false) + ); + assert_eq!(phoenix_author_nsfw_bit(true, Some(true), None), None); + } + + #[test] + fn phoenix_nsfw_matches_existing_label_union() { + assert!(phoenix_nsfw_from_safety(true, false, false, false)); + assert!(phoenix_nsfw_from_safety(false, true, false, false)); + assert!(phoenix_nsfw_from_safety(false, false, true, false)); + assert!(phoenix_nsfw_from_safety(false, false, false, true)); + assert!(!phoenix_nsfw_from_safety(false, false, false, false)); + } +} diff --git a/home-mixer/models/candidate.rs b/home-mixer/models/candidate.rs index a9d747d6..bc3f0277 100644 --- a/home-mixer/models/candidate.rs +++ b/home-mixer/models/candidate.rs @@ -243,9 +243,10 @@ impl CandidateHelpers for PostCandidate { quoted_author_id: self.quoted_user_id.unwrap_or(0), in_reply_to_tweet_id: self.in_reply_to_tweet_id.unwrap_or(0), is_author_followed_by_user: is_followed_by_viewer, - safety_label_mask: if self.retweeted_user_id.is_none() - && self.nsfw_author_phoenix.unwrap_or(false) - { + // Phoenix hashes get_original_author_id(). authorIsNsfwSeq must + // describe that account on retweets too. The old + // retweeted_user_id.is_none() guard zeroed the bit on every RT. + safety_label_mask: if self.nsfw_author_phoenix.unwrap_or(false) { SAFETY_BIT_AUTHOR_NSFW } else { 0 @@ -354,4 +355,53 @@ mod tests { SafetyLabelType::BOUNCE ); } + + #[test] + fn as_tweet_info_stamps_author_nsfw_on_originals() { + let nsfw = PostCandidate { + tweet_id: 11, + author_id: 21, + nsfw_author_phoenix: Some(true), + ..Default::default() + }; + assert_eq!( + nsfw.as_tweet_info(false).safety_label_mask, + SAFETY_BIT_AUTHOR_NSFW + ); + + let clean = PostCandidate { + tweet_id: 11, + author_id: 21, + nsfw_author_phoenix: Some(false), + ..Default::default() + }; + assert_eq!(clean.as_tweet_info(false).safety_label_mask, 0); + } + + #[test] + fn as_tweet_info_stamps_author_nsfw_on_retweets() { + let rt = PostCandidate { + tweet_id: 11, + author_id: 21, + retweeted_tweet_id: Some(12), + retweeted_user_id: Some(22), + nsfw_author_phoenix: Some(true), + ..Default::default() + }; + let info = rt.as_tweet_info(true); + assert_eq!(info.author_id, 22); + assert_eq!(info.safety_label_mask, SAFETY_BIT_AUTHOR_NSFW); + + let clean_origin = PostCandidate { + tweet_id: 11, + author_id: 21, + retweeted_tweet_id: Some(12), + retweeted_user_id: Some(22), + nsfw_author_phoenix: Some(false), + nsfw_author: Some(true), + nsfw_author_ads: Some(true), + ..Default::default() + }; + assert_eq!(clean_origin.as_tweet_info(false).safety_label_mask, 0); + } }