diff --git a/visibility-filtering/models/safety_labels.rs b/visibility-filtering/models/safety_labels.rs index 2ef75f06..8e6357cb 100644 --- a/visibility-filtering/models/safety_labels.rs +++ b/visibility-filtering/models/safety_labels.rs @@ -1,28 +1,120 @@ pub use xai_x_thrift::tweet_safety_label::SafetyLabelType; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use xai_visibility_filtering_proto as vf_pb; #[derive(Clone, Debug, Default)] -pub struct SafetyLabelMap(HashSet); +pub struct SafetyLabelMap { + types: HashSet, + /// Per-type country scope from proto. Missing or empty = worldwide. + countries: HashMap>, +} impl SafetyLabelMap { pub fn new(label_types: HashSet) -> Self { - Self(label_types) + Self { + types: label_types, + countries: HashMap::new(), + } + } + + pub fn with_country_scope(mut self, label: SafetyLabelType, countries: Vec) -> Self { + self.types.insert(label); + self.countries.insert(label, countries); + self } pub fn from_proto_label_types(proto: &vf_pb::SafetyLabelMap) -> Self { - Self( - proto - .labels - .keys() - .map(|label_type| SafetyLabelType(*label_type)) - .collect(), - ) + let mut types = HashSet::with_capacity(proto.labels.len()); + let mut countries = HashMap::with_capacity(proto.labels.len()); + for (label_type, label) in &proto.labels { + let lt = SafetyLabelType(*label_type); + types.insert(lt); + countries.insert(lt, label.applicable_countries.clone()); + } + Self { types, countries } } #[inline] pub fn has_label(&self, label_type: SafetyLabelType) -> bool { - self.0.contains(&label_type) + self.types.contains(&label_type) + } + + /// Type is present and in scope for the viewer country. + /// Empty applicable_countries is worldwide (current behavior). + /// A scoped label does not apply when the viewer country is missing + /// or outside the list. This is not expiry filtering (PR 106). + pub fn applies(&self, label_type: SafetyLabelType, viewer_country: Option<&str>) -> bool { + if !self.types.contains(&label_type) { + return false; + } + let Some(cs) = self.countries.get(&label_type) else { + return true; + }; + if cs.is_empty() { + return true; + } + let Some(country) = viewer_country else { + return false; + }; + cs.iter().any(|c| c.eq_ignore_ascii_case(country)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dna() -> SafetyLabelType { + SafetyLabelType::DO_NOT_AMPLIFY + } + + fn proto_with_countries(countries: Vec) -> vf_pb::SafetyLabelMap { + vf_pb::SafetyLabelMap { + labels: HashMap::from([( + dna().0, + vf_pb::SafetyLabel { + score: None, + applicable_users: Vec::new(), + holdback_experiment: None, + source: None, + created_at_msec: None, + expires_at_msec: None, + applicable_countries: countries, + safety_label_source: None, + }, + )]), + } + } + + #[test] + fn empty_countries_is_worldwide() { + let map = SafetyLabelMap::from_proto_label_types(&proto_with_countries(vec![])); + assert!(map.has_label(dna())); + assert!(map.applies(dna(), Some("us"))); + assert!(map.applies(dna(), Some("br"))); + assert!(map.applies(dna(), None)); + } + + #[test] + fn scoped_label_applies_only_in_listed_country() { + let map = SafetyLabelMap::from_proto_label_types(&proto_with_countries(vec![ + "br".to_string(), + ])); + assert!(map.has_label(dna())); + assert!(map.applies(dna(), Some("br"))); + assert!(map.applies(dna(), Some("BR"))); + assert!(!map.applies(dna(), Some("us"))); + assert!(!map.applies(dna(), None)); + } + + #[test] + fn scoped_label_does_not_apply_to_unlisted_country() { + let map = SafetyLabelMap::from_proto_label_types(&proto_with_countries(vec![ + "gb".to_string(), + "de".to_string(), + ])); + assert!(!map.applies(dna(), Some("us"))); + assert!(map.applies(dna(), Some("de"))); } } diff --git a/visibility-filtering/rules/context.rs b/visibility-filtering/rules/context.rs index 5b9b939a..1ed0f935 100644 --- a/visibility-filtering/rules/context.rs +++ b/visibility-filtering/rules/context.rs @@ -140,7 +140,10 @@ pub struct TweetPredicates<'a> { impl TweetPredicates<'_> { #[inline] pub fn has_safety_label(&self, label: SafetyLabelType) -> bool { - self.ctx.candidate.has_safety_label(label) + self.ctx.candidate.safety_labels.applies( + label, + self.ctx.viewer.country_code.as_deref(), + ) } #[inline] diff --git a/visibility-filtering/rules/fixtures.rs b/visibility-filtering/rules/fixtures.rs index 614bb9ab..0ab1d6ef 100644 --- a/visibility-filtering/rules/fixtures.rs +++ b/visibility-filtering/rules/fixtures.rs @@ -4,7 +4,7 @@ use crate::models::{ }; use crate::rules::rule_spec::RuleSpec; use crate::rules::test_context; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use xai_visibility_filtering::models::FilteredReason; use xai_x_thrift::user_labels::LabelValue; @@ -72,6 +72,7 @@ pub(crate) fn candidate() -> CandidateBuilder { ..Default::default() }, labels: HashSet::new(), + label_countries: HashMap::new(), user_labels: HashSet::new(), } } @@ -79,6 +80,7 @@ pub(crate) fn candidate() -> CandidateBuilder { pub(crate) struct CandidateBuilder { candidate: HydratedTweetCandidate, labels: HashSet, + label_countries: HashMap>, user_labels: HashSet, } @@ -98,6 +100,16 @@ impl CandidateBuilder { self } + pub(crate) fn with_label_countries( + mut self, + label: SafetyLabelType, + countries: Vec, + ) -> Self { + self.labels.insert(label); + self.label_countries.insert(label, countries); + self + } + pub(crate) fn with_author_user_label(mut self, label: LabelValue) -> Self { self.user_labels.insert(label); self @@ -135,8 +147,12 @@ impl CandidateBuilder { pub(crate) fn build(self) -> HydratedTweetCandidate { let mut candidate = self.candidate; - if !self.labels.is_empty() { - candidate.safety_labels = SafetyLabelMap::new(self.labels); + if !self.labels.is_empty() || !self.label_countries.is_empty() { + let mut map = SafetyLabelMap::new(self.labels); + for (label, countries) in self.label_countries { + map = map.with_country_scope(label, countries); + } + candidate.safety_labels = map; } if !self.user_labels.is_empty() { candidate.author_features.user_labels = UserLabelSet::new(self.user_labels); diff --git a/visibility-filtering/rules/golden_corpus.rs b/visibility-filtering/rules/golden_corpus.rs index cc4da723..a2af2603 100644 --- a/visibility-filtering/rules/golden_corpus.rs +++ b/visibility-filtering/rules/golden_corpus.rs @@ -125,6 +125,15 @@ fn labeled(label: SafetyLabelType) -> HydratedTweetCandidate { candidate().with_label(label).build() } +fn labeled_countries(label: SafetyLabelType, countries: &[&str]) -> HydratedTweetCandidate { + candidate() + .with_label_countries( + label, + countries.iter().map(|c| (*c).to_string()).collect(), + ) + .build() +} + fn labeled_media(label: SafetyLabelType) -> HydratedTweetCandidate { candidate().with_label(label).with_media().build() } @@ -895,6 +904,22 @@ fn oon_tweet_label_cases() -> Vec { expected_action: Drop(FilteredReason::PossiblyUndesirable), expected_decided_by: Some("DoNotAmplifyOonDropRule"), }, + Case { + name: "country_scoped_do_not_amplify_allows_out_of_country_oon", + level: TimelineHomeRecommendations, + viewer: viewer_in_country("us"), + candidate: labeled_countries(SafetyLabelType::DO_NOT_AMPLIFY, &["br"]), + expected_action: Allow, + expected_decided_by: None, + }, + Case { + name: "country_scoped_do_not_amplify_drops_in_country_oon", + level: TimelineHomeRecommendations, + viewer: viewer_in_country("br"), + candidate: labeled_countries(SafetyLabelType::DO_NOT_AMPLIFY, &["br"]), + expected_action: Drop(FilteredReason::PossiblyUndesirable), + expected_decided_by: Some("DoNotAmplifyOonDropRule"), + }, Case { name: "malicious_url_label_drops_oon", level: TimelineHomeRecommendations,