diff --git a/visibility-filtering/rules/registry.rs b/visibility-filtering/rules/registry.rs index 64ac414a..e02c8a87 100644 --- a/visibility-filtering/rules/registry.rs +++ b/visibility-filtering/rules/registry.rs @@ -12,8 +12,8 @@ use crate::rules::socialgraph_rules::{ DropExclusiveTweetContentRule, MutedRetweetsRule, ViewerBlocksAuthorRule, ViewerMutesAuthorRule, }; use crate::rules::tes_rules::{ - DropLegalTakendownPostRule, DropLocalLawsTakendownPostRule, DropStaleTweetsRule, - DropTweetsWithDmcaMediaRule, DropTweetsWithGeoRestrictedMediaRule, + DropGlobalTakendownPostRule, DropLegalTakendownPostRule, DropLocalLawsTakendownPostRule, + DropStaleTweetsRule, DropTweetsWithDmcaMediaRule, DropTweetsWithGeoRestrictedMediaRule, }; use crate::rules::tweet_flag_rules as tweet_flag; use crate::rules::tweet_label_drops as tweet_label; @@ -120,6 +120,7 @@ fn base_home_rules() -> Vec> { Box::new(DropStaleTweetsRule), Box::new(DropLegalTakendownPostRule), Box::new(DropLocalLawsTakendownPostRule), + Box::new(DropGlobalTakendownPostRule), Box::new(SensitiveViewerLoggedOutDropRule), Box::new(SensitiveViewerUnderageDropRule), Box::new(SensitiveViewerNoStatedAgeDropRule), @@ -173,7 +174,8 @@ fn timeline_home_recommendations_policy() -> Vec> { mod tests { use super::*; use crate::models::{ - HydratedTweetCandidate, MediaFeature, TweetFeatures, Viewer, ViewerFeatures, + HydratedTweetCandidate, MediaFeature, TakedownFeature, TweetFeatures, Viewer, + ViewerFeatures, }; use std::collections::HashMap; @@ -263,6 +265,72 @@ mod tests { } #[test] + fn post_level_dmca_drops_on_timeline_home() { + use xai_core_entities::entities::TakedownReason; + let policies = Policies::new(); + let candidate = HydratedTweetCandidate { + tweet_id: 1, + tweet_features: TweetFeatures { + takedown: TakedownFeature { + reasons: vec![TakedownReason::Dmca], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + let viewer = ViewerFeatures::default(); + + let timeline_home = policies.evaluate(SafetyLevel::TimelineHome, &viewer, &candidate); + assert!( + matches!(timeline_home.action, VfAction::Drop(_)), + "post-level DMCA must drop on TimelineHome, got {:?}", + timeline_home.action + ); + assert_eq!( + timeline_home.decided_by, + Some("DropGlobalTakendownPostRule") + ); + + let recommendations = policies.evaluate( + SafetyLevel::TimelineHomeRecommendations, + &viewer, + &candidate, + ); + assert!(matches!(recommendations.action, VfAction::Drop(_))); + assert_eq!( + recommendations.decided_by, + Some("DropGlobalTakendownPostRule") + ); + } + + fn tes_has_takedown_applied_drops_on_timeline_home() { + let policies = Policies::new(); + let candidate = HydratedTweetCandidate { + tweet_id: 1, + tweet_features: TweetFeatures { + takedown: TakedownFeature { + applied: true, + reasons: vec![], + }, + ..Default::default() + }, + ..Default::default() + }; + + let verdict = policies.evaluate( + SafetyLevel::TimelineHome, + &ViewerFeatures::default(), + &candidate, + ); + assert!( + matches!(verdict.action, VfAction::Drop(_)), + "TES has_takedown with no country reason must drop, got {:?}", + verdict.action + ); + assert_eq!(verdict.decided_by, Some("DropGlobalTakendownPostRule")); + } + fn dmca_media_drops_recommendations_only() { let policies = Policies::new(); let candidate = HydratedTweetCandidate { diff --git a/visibility-filtering/rules/tes_rules.rs b/visibility-filtering/rules/tes_rules.rs index 6f90983a..77060c8c 100644 --- a/visibility-filtering/rules/tes_rules.rs +++ b/visibility-filtering/rules/tes_rules.rs @@ -91,6 +91,37 @@ fn viewer_in_withheld_country( .any(|c| c.eq_ignore_ascii_case(viewer_country)) } +fn is_global_takedown_reason(reason: &TakedownReason) -> bool { + matches!( + reason, + TakedownReason::Dmca | TakedownReason::HatefulImagery | TakedownReason::Unknown + ) +} + +fn has_country_takedown_reason(reason: &TakedownReason) -> bool { + legal_takedown_country(reason).is_some() || local_laws_takedown_country(reason).is_some() +} + +/// Post-level DMCA / hateful-imagery / unknown takedowns, or TES `has_takedown` +/// with no country-scoped reason left for the geo rules. +pub struct DropGlobalTakendownPostRule; + +impl Rule for DropGlobalTakendownPostRule { + fn name(&self) -> &'static str { + "DropGlobalTakendownPostRule" + } + + fn evaluate(&self, context: &RuleContext<'_>) -> VfAction { + let takedown = &context.candidate().tweet_features.takedown; + let has_global_reason = takedown.reasons.iter().any(is_global_takedown_reason); + let has_country_reason = takedown.reasons.iter().any(has_country_takedown_reason); + if has_global_reason || (takedown.applied && !has_country_reason) { + return VfAction::Drop(FilteredReason::UnspecifiedReason); + } + VfAction::Allow + } +} + pub struct DropTweetsWithGeoRestrictedMediaRule; const WORLDWIDE_COUNTRY_CODE: &str = "xx"; @@ -453,6 +484,118 @@ mod tests { .evaluate(&crate::rules::test_context(&viewer_with_country("de"), &c)), VfAction::Allow )); + assert!(matches!( + DropGlobalTakendownPostRule + .evaluate(&crate::rules::test_context(&viewer_with_country("de"), &c)), + VfAction::Drop(_) + )); + } + + fn takedown_candidate(takedown: TakedownFeature) -> HydratedTweetCandidate { + HydratedTweetCandidate { + tweet_id: 1, + author_id: 100, + tweet_features: TweetFeatures { + takedown, + ..Default::default() + }, + ..Default::default() + } + } + + #[test] + fn global_takedown_drops_dmca() { + let c = takedown_candidate(TakedownFeature { + reasons: vec![TakedownReason::Dmca], + ..Default::default() + }); + assert!(matches!( + DropGlobalTakendownPostRule.evaluate(&crate::rules::test_context(&viewer(), &c)), + VfAction::Drop(_) + )); + } + + #[test] + fn global_takedown_drops_hateful_imagery() { + let c = takedown_candidate(TakedownFeature { + reasons: vec![TakedownReason::HatefulImagery], + ..Default::default() + }); + assert!(matches!( + DropGlobalTakendownPostRule.evaluate(&crate::rules::test_context(&viewer(), &c)), + VfAction::Drop(_) + )); + } + + #[test] + fn global_takedown_drops_unknown() { + let c = takedown_candidate(TakedownFeature { + reasons: vec![TakedownReason::Unknown], + ..Default::default() + }); + assert!(matches!( + DropGlobalTakendownPostRule.evaluate(&crate::rules::test_context(&viewer(), &c)), + VfAction::Drop(_) + )); + } + + #[test] + fn global_takedown_drops_applied_with_no_country_reason() { + let c = takedown_candidate(TakedownFeature { + applied: true, + reasons: vec![], + }); + assert!(matches!( + DropGlobalTakendownPostRule.evaluate(&crate::rules::test_context(&viewer(), &c)), + VfAction::Drop(_) + )); + } + + #[test] + fn global_takedown_allows_country_only_legal_request() { + let c = takedown_candidate(TakedownFeature { + applied: true, + reasons: vec![TakedownReason::LegalRequest { + country_code: "de".to_string(), + }], + }); + assert!(matches!( + DropGlobalTakendownPostRule + .evaluate(&crate::rules::test_context(&viewer_with_country("us"), &c)), + VfAction::Allow + )); + } + + #[test] + fn global_takedown_drops_even_for_author() { + let mut c = takedown_candidate(TakedownFeature { + reasons: vec![TakedownReason::Dmca], + ..Default::default() + }); + c.author_id = 999; + assert!(matches!( + DropGlobalTakendownPostRule.evaluate(&crate::rules::test_context(&viewer(), &c)), + VfAction::Drop(_) + )); + } + + #[test] + fn global_takedown_does_not_consume_media_dmca() { + let c = HydratedTweetCandidate { + tweet_id: 1, + tweet_features: TweetFeatures { + media: MediaFeature { + has_dmca_media: true, + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + assert!(matches!( + DropGlobalTakendownPostRule.evaluate(&crate::rules::test_context(&viewer(), &c)), + VfAction::Allow + )); } fn geo_candidate(allow: &[&str], deny: &[&str]) -> HydratedTweetCandidate {