Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 103 additions & 11 deletions visibility-filtering/models/safety_labels.rs
Original file line number Diff line number Diff line change
@@ -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<SafetyLabelType>);
pub struct SafetyLabelMap {
types: HashSet<SafetyLabelType>,
/// Per-type country scope from proto. Missing or empty = worldwide.
countries: HashMap<SafetyLabelType, Vec<String>>,
}

impl SafetyLabelMap {
pub fn new(label_types: HashSet<SafetyLabelType>) -> Self {
Self(label_types)
Self {
types: label_types,
countries: HashMap::new(),
}
}

pub fn with_country_scope(mut self, label: SafetyLabelType, countries: Vec<String>) -> 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<String>) -> 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")));
}
}
5 changes: 4 additions & 1 deletion visibility-filtering/rules/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
22 changes: 19 additions & 3 deletions visibility-filtering/rules/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -72,13 +72,15 @@ pub(crate) fn candidate() -> CandidateBuilder {
..Default::default()
},
labels: HashSet::new(),
label_countries: HashMap::new(),
user_labels: HashSet::new(),
}
}

pub(crate) struct CandidateBuilder {
candidate: HydratedTweetCandidate,
labels: HashSet<SafetyLabelType>,
label_countries: HashMap<SafetyLabelType, Vec<String>>,
user_labels: HashSet<LabelValue>,
}

Expand All @@ -98,6 +100,16 @@ impl CandidateBuilder {
self
}

pub(crate) fn with_label_countries(
mut self,
label: SafetyLabelType,
countries: Vec<String>,
) -> 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
Expand Down Expand Up @@ -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);
Expand Down
25 changes: 25 additions & 0 deletions visibility-filtering/rules/golden_corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -895,6 +904,22 @@ fn oon_tweet_label_cases() -> Vec<Case> {
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,
Expand Down