diff --git a/home-mixer/ads/tests/partition_organic_blender_tests.rs b/home-mixer/ads/tests/partition_organic_blender_tests.rs index e54ef528..3b35f115 100644 --- a/home-mixer/ads/tests/partition_organic_blender_tests.rs +++ b/home-mixer/ads/tests/partition_organic_blender_tests.rs @@ -110,6 +110,38 @@ fn ad_bsr_levels(items: &[FeedItem]) -> Vec { .collect() } +fn organic_ids(items: &[FeedItem]) -> Vec { + items + .iter() + .filter_map(|item| match &item.item { + Some(feed_item::Item::Post(p)) => Some(p.tweet_id), + _ => None, + }) + .collect() +} + +#[test] +fn unspecified_is_not_an_ads_avoid_post() { + let post = make_post(1); + assert_eq!( + post.brand_safety_verdict(), + BrandSafetyVerdict::VerdictUnspecified + ); + assert!( + !crate::ads::util::has_avoid(&post), + "Unspecified must not be treated as MediumRisk avoid" + ); +} + +fn make_safe_post(tweet_id: u64) -> ScoredPost { + ScoredPost { + tweet_id, + brand_safety_verdict: BrandSafetyVerdict::Safe.into(), + score: 1.0 - (tweet_id as f32 * 0.01), + ..Default::default() + } +} + #[test] fn test_no_ads() { let posts = vec![make_post(1), make_post(2), make_post(3)]; @@ -118,6 +150,37 @@ fn test_no_ads() { assert_eq!(ad_count(&result), 0); } +#[test] +fn unspecified_top_organic_keeps_rank_when_ads_are_placed() { + let mut top = make_post(1); + top.score = 10.0; + top.brand_safety_verdict = BrandSafetyVerdict::VerdictUnspecified.into(); + let mut posts = vec![top]; + posts.extend((2..=6).map(make_safe_post)); + let result = blend_impl(posts, vec![make_normal_ad(100)], 5); + assert!(ad_count(&result) > 0); + assert_eq!( + organic_ids(&result).first().copied(), + Some(1), + "an unspecified ads-VF verdict must not demote the top organic" + ); +} + +#[test] +fn medium_risk_top_organic_is_kept_off_ad_neighbors() { + let mut top = make_avoid_post(1); + top.score = 10.0; + let mut posts = vec![top]; + posts.extend((2..=6).map(make_safe_post)); + let result = blend_impl(posts, vec![make_normal_ad(100)], 5); + assert!(ad_count(&result) > 0); + assert_ne!( + organic_ids(&result).first().copied(), + Some(1), + "a real MediumRisk ads verdict may still be kept off ad neighbors" + ); +} + #[test] fn test_no_posts() { let result = blend_impl(vec![], vec![make_normal_ad(100)], 5); diff --git a/home-mixer/candidate_hydrators/ads_brand_safety_vf_hydrator.rs b/home-mixer/candidate_hydrators/ads_brand_safety_vf_hydrator.rs index 3ac4e132..05a34f0d 100644 --- a/home-mixer/candidate_hydrators/ads_brand_safety_vf_hydrator.rs +++ b/home-mixer/candidate_hydrators/ads_brand_safety_vf_hydrator.rs @@ -1,6 +1,6 @@ use crate::models::brand_safety::{ botmaker_rule_category, botmaker_rule_id_from, compute_verdict, compute_verdict_v2, - truncate_description, worst_verdict, BrandSafetyVerdict, + is_active_label, truncate_description, worst_verdict, BrandSafetyVerdict, }; use crate::models::candidate::{PostCandidate, SafetyLabelInfo}; use crate::models::query::ScoredPostsQuery; @@ -10,21 +10,24 @@ use std::sync::Arc; use tonic::async_trait; use xai_candidate_pipeline::hydrator::Hydrator; use xai_safety_label_store::types::SafetyLabelMap; -use xai_stats_receiver::global_stats_receiver; use xai_visibility_filtering::tweet_safety_label::TweetSafetyLabelClient; -const NSFW_AUTHOR_METRIC: &str = "AdsBrandSafetyVf.nsfw_author"; - +/// Tweet-label ads adjacency for organic posts. Ads-only author inventory +/// (`nsfw_author_ads`) is not applied here: that flag is broader than organic +/// NSFW and must not rewrite the verdict the For You blender uses to reorder. pub struct AdsBrandSafetyVfHydrator { pub client: Arc, } fn to_safety_label_infos(labels: &SafetyLabelMap) -> impl Iterator { - labels.iter().map(|(k, v)| SafetyLabelInfo { - label_type: *k, - description: v.source.as_deref().map(truncate_description), - source: botmaker_rule_id_from(v).map(|id| botmaker_rule_category(id).to_string()), - }) + labels + .iter() + .filter(|(_, v)| is_active_label(v)) + .map(|(k, v)| SafetyLabelInfo { + label_type: *k, + description: v.source.as_deref().map(truncate_description), + source: botmaker_rule_id_from(v).map(|id| botmaker_rule_category(id).to_string()), + }) } #[async_trait] @@ -61,9 +64,6 @@ impl Hydrator for AdsBrandSafetyVfHydrator { compute_verdict }; - let mut nsfw_author_seen: u64 = 0; - let mut nsfw_author_dropped: u64 = 0; - let results: Vec> = candidates .iter() .map(|c| { @@ -99,15 +99,6 @@ impl Hydrator for AdsBrandSafetyVfHydrator { } } - if c.nsfw_author_ads == Some(true) { - nsfw_author_seen += 1; - let before = verdict; - verdict = worst_verdict(&verdict, &BrandSafetyVerdict::HighRisk); - if verdict != before { - nsfw_author_dropped += 1; - } - } - safety_labels.sort_unstable_by_key(|l| i32::from(l.label_type)); safety_labels.dedup_by(|a, b| a.label_type == b.label_type); @@ -119,19 +110,6 @@ impl Hydrator for AdsBrandSafetyVfHydrator { }) .collect(); - if let Some(receiver) = global_stats_receiver() { - if nsfw_author_seen > 0 { - receiver.incr(NSFW_AUTHOR_METRIC, &[("outcome", "seen")], nsfw_author_seen); - } - if nsfw_author_dropped > 0 { - receiver.incr( - NSFW_AUTHOR_METRIC, - &[("outcome", "dropped")], - nsfw_author_dropped, - ); - } - } - results } @@ -481,7 +459,7 @@ mod tests { } #[tokio::test] - async fn nsfw_author_escalates_to_high_risk() { + async fn nsfw_author_ads_does_not_escalate_organic_verdict() { let mut labels: SafetyLabelMap = HashMap::new(); labels.insert(SafetyLabelType::GROK_SFA, SafetyLabel::default()); let client = Arc::new(FakeVfClient { @@ -504,7 +482,8 @@ mod tests { let hydrated = results[0].as_ref().unwrap(); assert_eq!( hydrated.brand_safety_verdict, - Some(BrandSafetyVerdict::HighRisk) + Some(BrandSafetyVerdict::Safe), + "ads-only author inventory must not rewrite the organic brand-safety verdict" ); } @@ -535,4 +514,80 @@ mod tests { Some(BrandSafetyVerdict::Safe) ); } + + #[tokio::test] + async fn expired_community_note_does_not_keep_medium_risk() { + let mut labels: SafetyLabelMap = HashMap::new(); + labels.insert(SafetyLabelType::GROK_SFA, SafetyLabel::default()); + labels.insert( + SafetyLabelType::NSFA_COMMUNITY_NOTE, + SafetyLabel { + expires_at_msec: Some(1), + ..Default::default() + }, + ); + let client = Arc::new(FakeVfClient { + batch: SafetyLabelsBatch { + labels: HashMap::from([(1, labels)]), + failures: HashMap::new(), + }, + }); + let hydrator = AdsBrandSafetyVfHydrator { client }; + let candidates = vec![PostCandidate { + tweet_id: 1, + ..Default::default() + }]; + + let results = hydrator + .hydrate(&ScoredPostsQuery::default(), &candidates) + .await; + + let hydrated = results[0].as_ref().unwrap(); + assert_eq!( + hydrated.brand_safety_verdict, + Some(BrandSafetyVerdict::Safe) + ); + assert!(!hydrated + .safety_labels + .iter() + .any(|l| l.label_type == SafetyLabelType::NSFA_COMMUNITY_NOTE)); + } + + #[tokio::test] + async fn active_community_note_is_medium_risk_and_listed() { + let mut labels: SafetyLabelMap = HashMap::new(); + labels.insert(SafetyLabelType::GROK_SFA, SafetyLabel::default()); + labels.insert( + SafetyLabelType::NSFA_COMMUNITY_NOTE, + SafetyLabel { + expires_at_msec: Some(i64::MAX), + ..Default::default() + }, + ); + let client = Arc::new(FakeVfClient { + batch: SafetyLabelsBatch { + labels: HashMap::from([(1, labels)]), + failures: HashMap::new(), + }, + }); + let hydrator = AdsBrandSafetyVfHydrator { client }; + let candidates = vec![PostCandidate { + tweet_id: 1, + ..Default::default() + }]; + + let results = hydrator + .hydrate(&ScoredPostsQuery::default(), &candidates) + .await; + + let hydrated = results[0].as_ref().unwrap(); + assert_eq!( + hydrated.brand_safety_verdict, + Some(BrandSafetyVerdict::MediumRisk) + ); + assert!(hydrated + .safety_labels + .iter() + .any(|l| l.label_type == SafetyLabelType::NSFA_COMMUNITY_NOTE)); + } } diff --git a/home-mixer/candidate_hydrators/gizmoduck_hydrator.rs b/home-mixer/candidate_hydrators/gizmoduck_hydrator.rs index c2eeb9e9..7053518b 100644 --- a/home-mixer/candidate_hydrators/gizmoduck_hydrator.rs +++ b/home-mixer/candidate_hydrators/gizmoduck_hydrator.rs @@ -1,11 +1,12 @@ use crate::clients::gizmoduck_client::GizmoduckClient; use crate::models::candidate::PostCandidate; use crate::models::query::ScoredPostsQuery; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use tonic::async_trait; use xai_candidate_pipeline::component_library::utils::{default_quick_cache, QuickCache}; use xai_candidate_pipeline::hydrator::{CacheStore, CachedHydrator}; +use xai_core_entities::entities::GizmoduckUserResult; use xai_x_thrift::user_labels::LabelValue; pub struct GizmoduckCandidateHydrator { @@ -23,6 +24,117 @@ impl GizmoduckCandidateHydrator { } } +/// Store outcome for one requested account. +/// +/// `Unknown` is a miss or a read error. Hydrator `Err` is dropped by +/// `update_all`, so unknown trust must be written as fail-closed values on +/// an `Ok` candidate or the post keeps empty NSFW/size fields and is scored +/// as clean / size-neutral. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum AuthorLookup { + Found { + followers_count: Option, + screen_name: Option, + nsfw: bool, + nsfw_ads: bool, + }, + ConfirmedMissing, + Unknown, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AuthorHydration { + pub author_followers_count: Option, + pub author_screen_name: Option, + pub retweeted_screen_name: Option, + pub nsfw_author: Option, + pub nsfw_author_ads: Option, + pub nsfw_author_phoenix: Option, +} + +fn flag_from_lookup(lookup: &AuthorLookup, ads: bool) -> Option { + match lookup { + AuthorLookup::Found { nsfw, nsfw_ads, .. } => Some(if ads { *nsfw_ads } else { *nsfw }), + AuthorLookup::ConfirmedMissing => Some(false), + AuthorLookup::Unknown => None, + } +} + +fn or_trust( + poster: &AuthorLookup, + original: Option<&AuthorLookup>, + quoted: Option<&AuthorLookup>, + ads: bool, +) -> Option { + let mut known = false; + for lookup in [Some(poster), original, quoted].iter().copied().flatten() { + match flag_from_lookup(lookup, ads) { + None => return Some(true), + Some(true) => return Some(true), + Some(false) => known = true, + } + } + known.then_some(false) +} + +/// Size and NSFW for one candidate. +/// +/// Follower count is the content origin (`retweeted_user_id` when present). +/// A small account amplifying a large original must not inherit the small +/// account's size residual. Quotes stay on the quoter: a quote is new copy. +/// +/// NSFW is the OR of poster, original author, and quoted author. A store miss +/// or read error on any of those ids is labeled, not treated as clean. +pub(crate) fn hydrate_author_features( + poster: AuthorLookup, + original: Option, + quoted: Option, +) -> AuthorHydration { + let size_account = original.as_ref().unwrap_or(&poster); + let author_followers_count = match size_account { + AuthorLookup::Found { + followers_count, .. + } => *followers_count, + AuthorLookup::ConfirmedMissing | AuthorLookup::Unknown => None, + }; + let author_screen_name = match &poster { + AuthorLookup::Found { screen_name, .. } => screen_name.clone(), + AuthorLookup::ConfirmedMissing | AuthorLookup::Unknown => None, + }; + let retweeted_screen_name = match &original { + Some(AuthorLookup::Found { screen_name, .. }) => screen_name.clone(), + _ => None, + }; + let nsfw_author = or_trust(&poster, original.as_ref(), quoted.as_ref(), false); + // Ads inventory only. Must not be written onto the organic + // brand-safety verdict used by the For You ads blender. + let nsfw_broad = or_trust(&poster, original.as_ref(), quoted.as_ref(), true); + + AuthorHydration { + author_followers_count, + author_screen_name, + retweeted_screen_name, + nsfw_author, + nsfw_author_ads: nsfw_broad, + nsfw_author_phoenix: nsfw_broad, + } +} + +fn user_ids_to_fetch(candidates: &[PostCandidate]) -> Vec { + candidates + .iter() + .flat_map(|c| { + [Some(c.author_id), c.retweeted_user_id, c.quoted_user_id] + .into_iter() + .flatten() + .filter(|&id| id != 0) + .map(|id| id as i64) + }) + .collect::>() + .into_iter() + .collect() +} + #[async_trait] impl CachedHydrator for GizmoduckCandidateHydrator { type CacheKey = GizmoduckCacheKey; @@ -39,6 +151,7 @@ impl CachedHydrator for GizmoduckCandidateHydra GizmoduckCacheKey { author_id: candidate.author_id, retweeted_user_id: candidate.retweeted_user_id, + quoted_user_id: candidate.quoted_user_id, } } @@ -71,99 +184,33 @@ impl CachedHydrator for GizmoduckCandidateHydra candidates: &[PostCandidate], ) -> Vec> { let client = &self.gizmoduck_client; - - let user_ids_to_fetch: Vec = candidates - .iter() - .map(|c| c.author_id as i64) - .chain( - candidates - .iter() - .filter_map(|c| c.retweeted_user_id) - .map(|id| id as i64), - ) - .collect::>() - .into_iter() - .collect(); - + let user_ids_to_fetch = user_ids_to_fetch(candidates); let users = client.get_users(user_ids_to_fetch).await; - let mut hydrated_candidates = Vec::with_capacity(candidates.len()); - - for candidate in candidates { - let user = users.get(&(candidate.author_id as i64)); - let user = match user { - Some(Ok(Some(user))) => Ok(Some(user)), - Some(Ok(None)) | None => Ok(None), - Some(Err(err)) => Err(err.to_string()), - }; - - let retweet_user = candidate - .retweeted_user_id - .and_then(|retweeted_user_id| users.get(&(retweeted_user_id as i64))); - let retweet_user = match retweet_user { - Some(Ok(Some(user))) => Ok(Some(user)), - Some(Ok(None)) | None => Ok(None), - Some(Err(err)) => Err(err.to_string()), - }; - - let hydrated = match (user, retweet_user) { - (Ok(user), Ok(retweet_user)) => { - let user_counts = user.and_then(|user| user.user.as_ref().map(|u| &u.counts)); - let user_profile = user.and_then(|user| user.user.as_ref().map(|u| &u.profile)); - - let author_followers_count: Option = - user_counts.map(|x| x.followers_count).map(|x| x as i32); - let author_screen_name: Option = - user_profile.map(|x| x.screen_name.clone()); - - let retweet_profile = - retweet_user.and_then(|user| user.user.as_ref().map(|u| &u.profile)); - let retweeted_screen_name: Option = - retweet_profile.map(|x| x.screen_name.clone()); - - let author = user.and_then(|u| u.user.as_ref()); - let nsfw_author: Option = author.map(|u| { - u.safety.nsfw_admin - || u.safety.nsfw_user - || u.labels - .labels - .iter() - .any(|label| label.label_value == LabelValue::NSFW_HIGH_PRECISION.0) - }); - let nsfw_author_ads: Option = author.map(|u| { - nsfw_author.unwrap_or(false) - || u.labels.labels.iter().any(|label| { - 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 - ) - }) - }); - - Ok(PostCandidate { - author_followers_count, - author_screen_name, - retweeted_screen_name, - nsfw_author, - nsfw_author_ads, - nsfw_author_phoenix, - ..Default::default() - }) - } - (Err(err), _) | (_, Err(err)) => Err(err), - }; - hydrated_candidates.push(hydrated); - } - - hydrated_candidates + candidates + .iter() + .map(|candidate| { + let poster = lookup_user(&users, candidate.author_id); + let original = candidate + .retweeted_user_id + .filter(|&id| id != 0) + .map(|id| lookup_user(&users, id)); + let quoted = candidate + .quoted_user_id + .filter(|&id| id != 0) + .map(|id| lookup_user(&users, id)); + let hydrated = hydrate_author_features(poster, original, quoted); + Ok(PostCandidate { + author_followers_count: hydrated.author_followers_count, + author_screen_name: hydrated.author_screen_name, + retweeted_screen_name: hydrated.retweeted_screen_name, + nsfw_author: hydrated.nsfw_author, + nsfw_author_ads: hydrated.nsfw_author_ads, + nsfw_author_phoenix: hydrated.nsfw_author_phoenix, + ..Default::default() + }) + }) + .collect() } fn update(&self, candidate: &mut PostCandidate, hydrated: PostCandidate) { @@ -176,10 +223,45 @@ impl CachedHydrator for GizmoduckCandidateHydra } } +fn lookup_user( + users: &HashMap, E>>, + user_id: u64, +) -> AuthorLookup { + match users.get(&(user_id as i64)) { + Some(Ok(Some(result))) => match result.user.as_ref() { + Some(user) => { + let nsfw = user.safety.nsfw_admin + || user.safety.nsfw_user + || user + .labels + .labels + .iter() + .any(|label| label.label_value == LabelValue::NSFW_HIGH_PRECISION.0); + let nsfw_ads = nsfw + || user + .labels + .labels + .iter() + .any(|label| label.label_value == LabelValue::POSSIBLY_NSFW_ACCOUNT.0); + AuthorLookup::Found { + followers_count: Some(user.counts.followers_count as i32), + screen_name: Some(user.profile.screen_name.clone()), + nsfw, + nsfw_ads, + } + } + None => AuthorLookup::ConfirmedMissing, + }, + Some(Ok(None)) => AuthorLookup::ConfirmedMissing, + Some(Err(_)) | None => AuthorLookup::Unknown, + } +} + #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct GizmoduckCacheKey { pub author_id: u64, pub retweeted_user_id: Option, + pub quoted_user_id: Option, } #[derive(Clone, Debug)] @@ -191,3 +273,138 @@ pub struct GizmoduckCacheValue { pub nsfw_author_ads: Option, pub nsfw_author_phoenix: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + fn found(followers: i32, nsfw: bool, nsfw_ads: bool) -> AuthorLookup { + AuthorLookup::Found { + followers_count: Some(followers), + screen_name: Some(format!("u{followers}")), + nsfw, + nsfw_ads, + } + } + + #[test] + fn retweet_uses_original_author_follower_count() { + let out = hydrate_author_features( + found(50, false, false), + Some(found(1_000_000, false, false)), + None, + ); + assert_eq!(out.author_followers_count, Some(1_000_000)); + assert_eq!(out.author_screen_name.as_deref(), Some("u50")); + assert_eq!(out.retweeted_screen_name.as_deref(), Some("u1000000")); + assert_eq!(out.nsfw_author, Some(false)); + assert_eq!(out.nsfw_author_phoenix, Some(false)); + } + + #[test] + fn original_post_uses_poster_follower_count() { + let out = hydrate_author_features(found(12_000, false, false), None, None); + assert_eq!(out.author_followers_count, Some(12_000)); + assert_eq!(out.nsfw_author, Some(false)); + } + + #[test] + fn quote_keeps_quoter_follower_count() { + let out = hydrate_author_features( + found(80, false, false), + None, + Some(found(2_000_000, false, false)), + ); + assert_eq!(out.author_followers_count, Some(80)); + assert_eq!(out.nsfw_author, Some(false)); + } + + #[test] + fn nsfw_original_author_labels_retweet() { + let out = hydrate_author_features( + found(50, false, false), + Some(found(1_000_000, true, true)), + None, + ); + assert_eq!(out.nsfw_author, Some(true)); + assert_eq!(out.nsfw_author_ads, Some(true)); + assert_eq!(out.nsfw_author_phoenix, Some(true)); + assert_eq!(out.author_followers_count, Some(1_000_000)); + } + + #[test] + fn nsfw_quoted_author_labels_quote() { + let out = + hydrate_author_features(found(80, false, false), None, Some(found(9, true, true))); + assert_eq!(out.author_followers_count, Some(80)); + assert_eq!(out.nsfw_author, Some(true)); + assert_eq!(out.nsfw_author_phoenix, Some(true)); + } + + #[test] + fn store_miss_on_original_author_fails_closed() { + let out = + hydrate_author_features(found(50, false, false), Some(AuthorLookup::Unknown), None); + assert_eq!(out.nsfw_author, Some(true)); + assert_eq!(out.nsfw_author_ads, Some(true)); + assert_eq!(out.nsfw_author_phoenix, Some(true)); + assert_eq!(out.author_followers_count, None); + } + + #[test] + fn store_miss_on_quoted_author_fails_closed() { + let out = + hydrate_author_features(found(80, false, false), None, Some(AuthorLookup::Unknown)); + assert_eq!(out.nsfw_author, Some(true)); + assert_eq!(out.author_followers_count, Some(80)); + } + + #[test] + fn store_miss_on_poster_fails_closed() { + let out = hydrate_author_features(AuthorLookup::Unknown, None, None); + assert_eq!(out.nsfw_author, Some(true)); + assert_eq!(out.nsfw_author_ads, Some(true)); + assert_eq!(out.nsfw_author_phoenix, Some(true)); + assert_eq!(out.author_followers_count, None); + } + + #[test] + fn confirmed_missing_original_is_not_a_store_miss() { + let out = hydrate_author_features( + found(50, false, false), + Some(AuthorLookup::ConfirmedMissing), + None, + ); + assert_eq!(out.nsfw_author, Some(false)); + assert_eq!(out.author_followers_count, None); + } + + #[test] + fn fetch_ids_include_retweeted_and_quoted_authors() { + let ids = user_ids_to_fetch(&[PostCandidate { + author_id: 1, + retweeted_user_id: Some(2), + quoted_user_id: Some(3), + ..Default::default() + }]); + let set: HashSet = ids.into_iter().collect(); + assert_eq!(set, HashSet::from([1, 2, 3])); + } + + #[test] + fn cache_key_includes_quoted_and_retweeted_ids() { + let key = GizmoduckCacheKey { + author_id: 1, + retweeted_user_id: Some(2), + quoted_user_id: Some(3), + }; + assert_ne!( + key, + GizmoduckCacheKey { + author_id: 1, + retweeted_user_id: Some(2), + quoted_user_id: None, + } + ); + } +} diff --git a/home-mixer/models/brand_safety.rs b/home-mixer/models/brand_safety.rs index 38cee469..a5cec808 100644 --- a/home-mixer/models/brand_safety.rs +++ b/home-mixer/models/brand_safety.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; use xai_x_thrift::tweet_safety_label::{SafetyLabel, SafetyLabelSource, SafetyLabelType}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -42,29 +43,79 @@ pub(crate) const LOW_RISK_LABELS: &[SafetyLabelType] = &[ const PTOS_CUTOFF_TWEET_ID: u64 = 2_054_275_414_225_846_272; +fn now_msec() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +// GetSafetyLabels returns expired rows. VF drop rules already skip them. +// Ads adjacency still used type presence, so a lapsed Community Note +// (or any other TTL label) kept the post MediumRisk / HighRisk. +fn is_unexpired(label: &SafetyLabel, now_msec: i64) -> bool { + match label.expires_at_msec { + Some(exp) if exp <= now_msec => false, + _ => true, + } +} + +fn has_active( + labels: &HashMap, + label_type: SafetyLabelType, + now_msec: i64, +) -> bool { + labels + .get(&label_type) + .is_some_and(|label| is_unexpired(label, now_msec)) +} + +pub(crate) fn is_active_label(label: &SafetyLabel) -> bool { + is_unexpired(label, now_msec()) +} + pub fn compute_verdict( labels: &HashMap, tweet_id: u64, ) -> BrandSafetyVerdict { - if HIGH_RISK_LABELS.iter().any(|l| labels.contains_key(l)) { + compute_verdict_at(labels, tweet_id, now_msec()) +} + +fn compute_verdict_at( + labels: &HashMap, + tweet_id: u64, + now_msec: i64, +) -> BrandSafetyVerdict { + if HIGH_RISK_LABELS + .iter() + .any(|l| has_active(labels, *l, now_msec)) + { return BrandSafetyVerdict::HighRisk; } - if MEDIUM_RISK_LABELS.iter().any(|l| labels.contains_key(l)) { + if MEDIUM_RISK_LABELS + .iter() + .any(|l| has_active(labels, *l, now_msec)) + { return BrandSafetyVerdict::MediumRisk; } - let scored_by_grok = labels.contains_key(&SafetyLabelType::GROK_SFA) - || labels.contains_key(&SafetyLabelType::GROK_NSFA_LIMITED); + let scored_by_grok = has_active(labels, SafetyLabelType::GROK_SFA, now_msec) + || has_active(labels, SafetyLabelType::GROK_NSFA_LIMITED, now_msec); if !scored_by_grok { return BrandSafetyVerdict::MediumRisk; } - if tweet_id >= PTOS_CUTOFF_TWEET_ID && !labels.contains_key(&SafetyLabelType::PTOS_REVIEWED) { + if tweet_id >= PTOS_CUTOFF_TWEET_ID + && !has_active(labels, SafetyLabelType::PTOS_REVIEWED, now_msec) + { return BrandSafetyVerdict::MediumRisk; } - if LOW_RISK_LABELS.iter().any(|l| labels.contains_key(l)) { + if LOW_RISK_LABELS + .iter() + .any(|l| has_active(labels, *l, now_msec)) + { return BrandSafetyVerdict::LowRisk; } @@ -100,27 +151,49 @@ pub(crate) fn compute_verdict_v2( labels: &HashMap, tweet_id: u64, ) -> BrandSafetyVerdict { - if !V2_WRITTEN_LABELS.iter().any(|l| labels.contains_key(l)) { - return compute_verdict(labels, tweet_id); + compute_verdict_v2_at(labels, tweet_id, now_msec()) +} + +fn compute_verdict_v2_at( + labels: &HashMap, + tweet_id: u64, + now_msec: i64, +) -> BrandSafetyVerdict { + if !V2_WRITTEN_LABELS + .iter() + .any(|l| has_active(labels, *l, now_msec)) + { + return compute_verdict_at(labels, tweet_id, now_msec); } - if HIGH_RISK_LABELS.iter().any(|l| labels.contains_key(l)) { + if HIGH_RISK_LABELS + .iter() + .any(|l| has_active(labels, *l, now_msec)) + { return BrandSafetyVerdict::HighRisk; } - if MEDIUM_RISK_LABELS_V2.iter().any(|l| labels.contains_key(l)) { + if MEDIUM_RISK_LABELS_V2 + .iter() + .any(|l| has_active(labels, *l, now_msec)) + { return BrandSafetyVerdict::MediumRisk; } - let scored_by_grok = labels.contains_key(&SafetyLabelType::GROK_SFA_V2) - || labels.contains_key(&SafetyLabelType::GROK_NSFA_LIMITED_V2); + let scored_by_grok = has_active(labels, SafetyLabelType::GROK_SFA_V2, now_msec) + || has_active(labels, SafetyLabelType::GROK_NSFA_LIMITED_V2, now_msec); if !scored_by_grok { return BrandSafetyVerdict::MediumRisk; } - if tweet_id >= PTOS_CUTOFF_TWEET_ID && !labels.contains_key(&SafetyLabelType::PTOS_REVIEWED) { + if tweet_id >= PTOS_CUTOFF_TWEET_ID + && !has_active(labels, SafetyLabelType::PTOS_REVIEWED, now_msec) + { return BrandSafetyVerdict::MediumRisk; } - if LOW_RISK_LABELS_V2.iter().any(|l| labels.contains_key(l)) { + if LOW_RISK_LABELS_V2 + .iter() + .any(|l| has_active(labels, *l, now_msec)) + { return BrandSafetyVerdict::LowRisk; } @@ -135,6 +208,14 @@ pub fn worst_verdict(a: &BrandSafetyVerdict, b: &BrandSafetyVerdict) -> BrandSaf } } +/// Verdict written onto a scored organic post when ads brand-safety hydration +/// did not set one. Unspecified is not "avoid" in the For You ads blender, so +/// a missed ads-VF read cannot demote the organic by score. Following still +/// refuses to sit an ad next to Unspecified. +pub fn scored_post_verdict(verdict: Option) -> BrandSafetyVerdict { + verdict.unwrap_or(BrandSafetyVerdict::Unspecified) +} + pub(crate) fn botmaker_rule_id_from(label: &SafetyLabel) -> Option { label.safety_label_source.as_ref().and_then(|src| { if let SafetyLabelSource::BotMakerAction(action) = src { @@ -436,4 +517,156 @@ mod tests { BrandSafetyVerdict::MediumRisk ); } + + #[test] + fn missing_ads_vf_verdict_is_unspecified_not_medium_risk() { + assert_eq!(scored_post_verdict(None), BrandSafetyVerdict::Unspecified); + assert_eq!( + scored_post_verdict(Some(BrandSafetyVerdict::Safe)), + BrandSafetyVerdict::Safe + ); + assert_eq!( + scored_post_verdict(Some(BrandSafetyVerdict::MediumRisk)), + BrandSafetyVerdict::MediumRisk + ); + assert_eq!( + scored_post_verdict(Some(BrandSafetyVerdict::HighRisk)), + BrandSafetyVerdict::HighRisk + ); + } + + fn label_with_expiry(expires_at_msec: Option) -> SafetyLabel { + SafetyLabel { + expires_at_msec, + ..Default::default() + } + } + + fn labels_with_expiry( + types: &[(SafetyLabelType, Option)], + ) -> HashMap { + types + .iter() + .map(|(t, exp)| (*t, label_with_expiry(*exp))) + .collect() + } + + #[test] + fn expired_community_note_does_not_keep_medium_risk() { + let labels = labels_with_expiry(&[ + (SafetyLabelType::GROK_SFA, None), + (SafetyLabelType::NSFA_COMMUNITY_NOTE, Some(999)), + ]); + assert_eq!( + compute_verdict_at(&labels, PRE_CUTOFF_ID, 1_000), + BrandSafetyVerdict::Safe + ); + } + + #[test] + fn future_community_note_is_still_medium_risk() { + let labels = labels_with_expiry(&[ + (SafetyLabelType::GROK_SFA, None), + (SafetyLabelType::NSFA_COMMUNITY_NOTE, Some(2_000)), + ]); + assert_eq!( + compute_verdict_at(&labels, PRE_CUTOFF_ID, 1_000), + BrandSafetyVerdict::MediumRisk + ); + } + + #[test] + fn missing_expiry_on_community_note_is_treated_as_permanent() { + let labels = labels_with_expiry(&[ + (SafetyLabelType::GROK_SFA, None), + (SafetyLabelType::NSFA_COMMUNITY_NOTE, None), + ]); + assert_eq!( + compute_verdict_at(&labels, PRE_CUTOFF_ID, 1_000), + BrandSafetyVerdict::MediumRisk + ); + } + + #[test] + fn expiry_exactly_now_is_not_treated_as_active() { + let labels = labels_with_expiry(&[ + (SafetyLabelType::GROK_SFA, None), + (SafetyLabelType::NSFA_COMMUNITY_NOTE, Some(1_000)), + ]); + assert_eq!( + compute_verdict_at(&labels, PRE_CUTOFF_ID, 1_000), + BrandSafetyVerdict::Safe + ); + } + + #[test] + fn expired_sibling_does_not_hide_an_active_medium_risk_label() { + let labels = labels_with_expiry(&[ + (SafetyLabelType::GROK_SFA, None), + (SafetyLabelType::NSFA_COMMUNITY_NOTE, Some(500)), + (SafetyLabelType::NSFW_TEXT, Some(2_000)), + ]); + assert_eq!( + compute_verdict_at(&labels, PRE_CUTOFF_ID, 1_000), + BrandSafetyVerdict::MediumRisk + ); + } + + #[test] + fn expired_high_risk_label_does_not_keep_high_risk() { + let labels = labels_with_expiry(&[ + (SafetyLabelType::GROK_SFA, None), + (SafetyLabelType::NSFW_HIGH_PRECISION, Some(999)), + ]); + assert_eq!( + compute_verdict_at(&labels, PRE_CUTOFF_ID, 1_000), + BrandSafetyVerdict::Safe + ); + } + + #[test] + fn expired_low_risk_label_does_not_keep_low_risk() { + let labels = labels_with_expiry(&[ + (SafetyLabelType::GROK_SFA, None), + (SafetyLabelType::NSFA_LIMITED_INVENTORY, Some(999)), + ]); + assert_eq!( + compute_verdict_at(&labels, PRE_CUTOFF_ID, 1_000), + BrandSafetyVerdict::Safe + ); + } + + #[test] + fn expired_grok_sfa_is_treated_as_unscored() { + let labels = labels_with_expiry(&[(SafetyLabelType::GROK_SFA, Some(999))]); + assert_eq!( + compute_verdict_at(&labels, PRE_CUTOFF_ID, 1_000), + BrandSafetyVerdict::MediumRisk + ); + } + + #[test] + fn v2_expired_community_note_does_not_keep_medium_risk() { + let labels = labels_with_expiry(&[ + (SafetyLabelType::GROK_SFA_V2, None), + (SafetyLabelType::NSFA_COMMUNITY_NOTE, Some(999)), + ]); + assert_eq!( + compute_verdict_v2_at(&labels, PRE_CUTOFF_ID, 1_000), + BrandSafetyVerdict::Safe + ); + } + + #[test] + fn v2_expired_written_labels_fall_back_to_v1() { + let labels = labels_with_expiry(&[ + (SafetyLabelType::GROK_SFA, None), + (SafetyLabelType::GROK_SFA_V2, Some(999)), + (SafetyLabelType::NSFA_COMMUNITY_NOTE, None), + ]); + assert_eq!( + compute_verdict_v2_at(&labels, PRE_CUTOFF_ID, 1_000), + BrandSafetyVerdict::MediumRisk + ); + } } diff --git a/home-mixer/scored_posts_server.rs b/home-mixer/scored_posts_server.rs index ff84fb1e..0ce722a4 100644 --- a/home-mixer/scored_posts_server.rs +++ b/home-mixer/scored_posts_server.rs @@ -1,5 +1,4 @@ use crate::candidate_pipeline::phoenix_candidate_pipeline::PhoenixCandidatePipeline; -use crate::models::brand_safety::BrandSafetyVerdict; use crate::models::candidate::CandidateHelpers; use crate::models::candidate::PostCandidate; use crate::models::query::ScoredPostsQuery; @@ -100,10 +99,9 @@ fn candidates_to_scored_posts(candidates: &[PostCandidate]) -> Vec { candidate.tweet_type_metrics.clone().unwrap_or_default(), ), following_replied_user_ids: candidate.following_replied_user_ids.clone(), - brand_safety_verdict: candidate - .brand_safety_verdict - .unwrap_or(BrandSafetyVerdict::MediumRisk) - as i32, + brand_safety_verdict: crate::models::brand_safety::scored_post_verdict( + candidate.brand_safety_verdict, + ) as i32, safety_label_types: candidate .safety_labels .iter() @@ -231,3 +229,41 @@ fn safety_label_to_proto(label: SafetyLabelType) -> Option { }; Some(v.into()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::brand_safety::BrandSafetyVerdict; + use xai_home_mixer_proto::BrandSafetyVerdict as ProtoVerdict; + + #[test] + fn missing_ads_vf_verdict_serializes_as_unspecified() { + let scored = candidates_to_scored_posts(&[PostCandidate { + tweet_id: 1, + score: Some(10.0), + brand_safety_verdict: None, + ..Default::default() + }]); + assert_eq!( + scored[0].brand_safety_verdict, + ProtoVerdict::VerdictUnspecified as i32 + ); + assert_ne!( + scored[0].brand_safety_verdict, + BrandSafetyVerdict::MediumRisk as i32 + ); + } + + #[test] + fn explicit_medium_risk_is_preserved() { + let scored = candidates_to_scored_posts(&[PostCandidate { + tweet_id: 1, + brand_safety_verdict: Some(BrandSafetyVerdict::MediumRisk), + ..Default::default() + }]); + assert_eq!( + scored[0].brand_safety_verdict, + BrandSafetyVerdict::MediumRisk as i32 + ); + } +} diff --git a/home-mixer/sources/reverse_chron_posts_source.rs b/home-mixer/sources/reverse_chron_posts_source.rs index 94fdd129..a28f7a45 100644 --- a/home-mixer/sources/reverse_chron_posts_source.rs +++ b/home-mixer/sources/reverse_chron_posts_source.rs @@ -1,5 +1,4 @@ use crate::candidate_pipeline::reverse_chron_posts_pipeline::ReverseChronPostsPipeline; -use crate::models::brand_safety::BrandSafetyVerdict; use crate::models::candidate::{CandidateHelpers, PostCandidate}; use crate::models::query::{FollowingPaginationMeta, ScoredPostsQuery}; use crate::params::FOLLOWING_POST_FETCH_SIZE; @@ -74,10 +73,33 @@ fn post_candidate_to_scored_post(candidate: &PostCandidate) -> ScoredPost { visibility_reason: candidate.visibility_reason.clone().map(|r| r.into()), tweet_type_metrics: Bytes::from(candidate.tweet_type_metrics.clone().unwrap_or_default()), following_replied_user_ids: candidate.following_replied_user_ids.clone(), - brand_safety_verdict: candidate - .brand_safety_verdict - .unwrap_or(BrandSafetyVerdict::MediumRisk) as i32, + brand_safety_verdict: crate::models::brand_safety::scored_post_verdict( + candidate.brand_safety_verdict, + ) as i32, tweet_text: candidate.tweet_text.clone(), ..Default::default() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::brand_safety::BrandSafetyVerdict; + use xai_home_mixer_proto::BrandSafetyVerdict as ProtoVerdict; + + #[test] + fn missing_ads_vf_verdict_does_not_become_medium_risk() { + let post = post_candidate_to_scored_post(&PostCandidate { + tweet_id: 7, + ..Default::default() + }); + assert_eq!( + post.brand_safety_verdict, + ProtoVerdict::VerdictUnspecified as i32 + ); + assert_ne!( + post.brand_safety_verdict, + BrandSafetyVerdict::MediumRisk as i32 + ); + } +}