diff --git a/home-mixer/ads/tests/partition_organic_blender_tests.rs b/home-mixer/ads/tests/partition_organic_blender_tests.rs index e54ef528..dcca8574 100644 --- a/home-mixer/ads/tests/partition_organic_blender_tests.rs +++ b/home-mixer/ads/tests/partition_organic_blender_tests.rs @@ -131,6 +131,38 @@ fn test_too_few_posts() { assert_eq!(ad_count(&result), 0); } +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 medium_risk_avoid_buries_higher_scored_organic() { + // Sink of Community Notes x rank: MediumRisk is ads-avoid. The blender + // pulls avoid posts out of Phoenix order and dumps them into filler. + // A note must not produce this verdict (brand_safety tests). This locks + // the bury so a later MediumRisk writer cannot hide. + let posts = vec![ + make_post(1), + make_avoid_post(2), + make_post(3), + make_post(4), + make_post(5), + ]; + let result = blend_impl(posts, vec![make_normal_ad(100)], 5); + let ids = organic_ids(&result); + assert_eq!( + ids, + vec![1, 3, 2, 4, 5], + "MediumRisk post 2 (score > 3) must lose rank to Safe post 3 when ads place: {ids:?}" + ); +} + #[test] fn test_basic_blending_all_safe() { let posts: Vec<_> = (1..=10).map(make_post).collect(); 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..8a38f0f4 100644 --- a/home-mixer/candidate_hydrators/ads_brand_safety_vf_hydrator.rs +++ b/home-mixer/candidate_hydrators/ads_brand_safety_vf_hydrator.rs @@ -535,4 +535,73 @@ mod tests { Some(BrandSafetyVerdict::Safe) ); } + + #[tokio::test] + async fn community_note_keeps_safe_verdict() { + let mut labels: SafetyLabelMap = HashMap::new(); + labels.insert(SafetyLabelType::GROK_SFA, SafetyLabel::default()); + labels.insert(SafetyLabelType::NSFA_COMMUNITY_NOTE, SafetyLabel::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 community_note_on_quoted_or_ancestor_does_not_escalate() { + let mut safe_labels: SafetyLabelMap = HashMap::new(); + safe_labels.insert(SafetyLabelType::GROK_SFA, SafetyLabel::default()); + let mut noted_labels: SafetyLabelMap = HashMap::new(); + noted_labels.insert(SafetyLabelType::GROK_SFA, SafetyLabel::default()); + noted_labels.insert(SafetyLabelType::NSFA_COMMUNITY_NOTE, SafetyLabel::default()); + let client = Arc::new(FakeVfClient { + batch: SafetyLabelsBatch { + labels: HashMap::from([ + (1, safe_labels), + (2, noted_labels.clone()), + (10, noted_labels), + ]), + failures: HashMap::new(), + }, + }); + let hydrator = AdsBrandSafetyVfHydrator { client }; + let candidates = vec![PostCandidate { + tweet_id: 1, + quoted_tweet_id: Some(2), + ancestors: vec![10], + ..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), + "a note on the quote or parent must not bury the reply" + ); + } } diff --git a/home-mixer/models/brand_safety.rs b/home-mixer/models/brand_safety.rs index 38cee469..1e726047 100644 --- a/home-mixer/models/brand_safety.rs +++ b/home-mixer/models/brand_safety.rs @@ -29,7 +29,7 @@ pub(crate) const MEDIUM_RISK_LABELS: &[SafetyLabelType] = &[ SafetyLabelType::NSFW_CARD_IMAGE, SafetyLabelType::DO_NOT_AMPLIFY, SafetyLabelType::MALICIOUS_URL, - SafetyLabelType::NSFA_COMMUNITY_NOTE, + // Notes are crowd context. They are not ads MediumRisk (see tests). SafetyLabelType::GROK_NSFA, SafetyLabelType::NSFW_TEXT, ]; @@ -78,7 +78,6 @@ pub(crate) const MEDIUM_RISK_LABELS_V2: &[SafetyLabelType] = &[ SafetyLabelType::NSFW_CARD_IMAGE, SafetyLabelType::DO_NOT_AMPLIFY, SafetyLabelType::MALICIOUS_URL, - SafetyLabelType::NSFA_COMMUNITY_NOTE, SafetyLabelType::GROK_NSFA_V2, SafetyLabelType::GROK_NSFA_EXPANDED_V2, SafetyLabelType::NSFW_TEXT, @@ -278,6 +277,58 @@ mod tests { ); } + #[test] + fn community_note_is_not_ads_medium_risk() { + assert!( + !MEDIUM_RISK_LABELS.contains(&SafetyLabelType::NSFA_COMMUNITY_NOTE), + "v1 MediumRisk must not include Community Notes" + ); + assert!( + !MEDIUM_RISK_LABELS_V2.contains(&SafetyLabelType::NSFA_COMMUNITY_NOTE), + "v2 MediumRisk must not include Community Notes" + ); + + let noted = labels_with(&[ + SafetyLabelType::GROK_SFA, + SafetyLabelType::NSFA_COMMUNITY_NOTE, + ]); + assert_eq!( + compute_verdict(¬ed, PRE_CUTOFF_ID), + BrandSafetyVerdict::Safe, + "a Grok-safe noted post must keep Phoenix-adjacent Safe verdict" + ); + + let noted_v2 = labels_with(&[ + SafetyLabelType::GROK_SFA_V2, + SafetyLabelType::NSFA_COMMUNITY_NOTE, + ]); + assert_eq!( + compute_verdict_v2(¬ed_v2, PRE_CUTOFF_ID), + BrandSafetyVerdict::Safe, + "v2 Grok-safe noted post must keep Safe verdict" + ); + } + + #[test] + fn community_note_does_not_mask_real_medium_or_unscored() { + let noted_nsfa = labels_with(&[ + SafetyLabelType::GROK_SFA, + SafetyLabelType::NSFA_COMMUNITY_NOTE, + SafetyLabelType::GROK_NSFA, + ]); + assert_eq!( + compute_verdict(¬ed_nsfa, PRE_CUTOFF_ID), + BrandSafetyVerdict::MediumRisk + ); + + let note_only = labels_with(&[SafetyLabelType::NSFA_COMMUNITY_NOTE]); + assert_eq!( + compute_verdict(¬e_only, PRE_CUTOFF_ID), + BrandSafetyVerdict::MediumRisk, + "unscored posts stay MediumRisk; the note is not a Grok score" + ); + } + #[test] fn v2_defers_to_v1_when_v2_has_not_ruled() { let v1_safe = labels_with(&[SafetyLabelType::GROK_SFA]); @@ -366,6 +417,10 @@ mod tests { SafetyLabelType::NSFA_HIGH_PRECISION, SafetyLabelType::GROK_NSFA, ], + &[ + SafetyLabelType::GROK_SFA, + SafetyLabelType::NSFA_COMMUNITY_NOTE, + ], ]; for v1_set in matrix {