diff --git a/visibility-filtering/models/safety_labels.rs b/visibility-filtering/models/safety_labels.rs index 2ef75f06..79a48caf 100644 --- a/visibility-filtering/models/safety_labels.rs +++ b/visibility-filtering/models/safety_labels.rs @@ -1,28 +1,107 @@ 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 viewer scope from proto. Missing or empty = every viewer. + users: HashMap>, +} impl SafetyLabelMap { pub fn new(label_types: HashSet) -> Self { - Self(label_types) + Self { + types: label_types, + users: HashMap::new(), + } + } + + pub fn with_user_scope(mut self, label: SafetyLabelType, users: Vec) -> Self { + self.types.insert(label); + self.users.insert(label, users); + 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 users = HashMap::with_capacity(proto.labels.len()); + for (label_type, label) in &proto.labels { + let lt = SafetyLabelType(*label_type); + types.insert(lt); + users.insert(lt, label.applicable_users.clone()); + } + Self { types, users } } #[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 this viewer. + /// Empty applicable_users is everyone (current behavior). + /// A scoped label does not apply when the viewer is missing or unlisted. + /// This is not country scope (PR 110) and not expiry (PR 106). + pub fn applies(&self, label_type: SafetyLabelType, viewer_id: Option) -> bool { + if !self.types.contains(&label_type) { + return false; + } + let Some(ids) = self.users.get(&label_type) else { + return true; + }; + if ids.is_empty() { + return true; + } + let Some(uid) = viewer_id else { + return false; + }; + ids.contains(&uid) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dna() -> SafetyLabelType { + SafetyLabelType::DO_NOT_AMPLIFY + } + + fn proto_with_users(users: Vec) -> vf_pb::SafetyLabelMap { + vf_pb::SafetyLabelMap { + labels: HashMap::from([( + dna().0, + vf_pb::SafetyLabel { + score: None, + applicable_users: users, + holdback_experiment: None, + source: None, + created_at_msec: None, + expires_at_msec: None, + applicable_countries: Vec::new(), + safety_label_source: None, + }, + )]), + } + } + + #[test] + fn empty_users_applies_to_every_viewer() { + let map = SafetyLabelMap::from_proto_label_types(&proto_with_users(vec![])); + assert!(map.has_label(dna())); + assert!(map.applies(dna(), Some(1))); + assert!(map.applies(dna(), Some(99))); + assert!(map.applies(dna(), None)); + } + + #[test] + fn scoped_label_applies_only_to_listed_viewer() { + let map = SafetyLabelMap::from_proto_label_types(&proto_with_users(vec![42])); + assert!(map.has_label(dna())); + assert!(map.applies(dna(), Some(42))); + assert!(!map.applies(dna(), Some(99))); + assert!(!map.applies(dna(), None)); } } diff --git a/visibility-filtering/rules/context.rs b/visibility-filtering/rules/context.rs index 5b9b939a..7b664b13 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.viewer_id()) } #[inline] diff --git a/visibility-filtering/rules/fixtures.rs b/visibility-filtering/rules/fixtures.rs index 614bb9ab..d65204d4 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_users: HashMap::new(), user_labels: HashSet::new(), } } @@ -79,6 +80,7 @@ pub(crate) fn candidate() -> CandidateBuilder { pub(crate) struct CandidateBuilder { candidate: HydratedTweetCandidate, labels: HashSet, + label_users: HashMap>, user_labels: HashSet, } @@ -98,6 +100,12 @@ impl CandidateBuilder { self } + pub(crate) fn with_label_users(mut self, label: SafetyLabelType, users: Vec) -> Self { + self.labels.insert(label); + self.label_users.insert(label, users); + self + } + pub(crate) fn with_author_user_label(mut self, label: LabelValue) -> Self { self.user_labels.insert(label); self @@ -135,8 +143,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_users.is_empty() { + let mut map = SafetyLabelMap::new(self.labels); + for (label, users) in self.label_users { + map = map.with_user_scope(label, users); + } + 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..8eee37a1 100644 --- a/visibility-filtering/rules/golden_corpus.rs +++ b/visibility-filtering/rules/golden_corpus.rs @@ -125,6 +125,12 @@ fn labeled(label: SafetyLabelType) -> HydratedTweetCandidate { candidate().with_label(label).build() } +fn labeled_users(label: SafetyLabelType, users: &[u64]) -> HydratedTweetCandidate { + candidate() + .with_label_users(label, users.to_vec()) + .build() +} + fn labeled_media(label: SafetyLabelType) -> HydratedTweetCandidate { candidate().with_label(label).with_media().build() } @@ -895,6 +901,22 @@ fn oon_tweet_label_cases() -> Vec { expected_action: Drop(FilteredReason::PossiblyUndesirable), expected_decided_by: Some("DoNotAmplifyOonDropRule"), }, + Case { + name: "user_scoped_do_not_amplify_allows_unlisted_viewer_oon", + level: TimelineHomeRecommendations, + viewer: viewer(VIEWER_ID), + candidate: labeled_users(SafetyLabelType::DO_NOT_AMPLIFY, &[42]), + expected_action: Allow, + expected_decided_by: None, + }, + Case { + name: "user_scoped_do_not_amplify_drops_listed_viewer_oon", + level: TimelineHomeRecommendations, + viewer: viewer(42), + candidate: labeled_users(SafetyLabelType::DO_NOT_AMPLIFY, &[42]), + expected_action: Drop(FilteredReason::PossiblyUndesirable), + expected_decided_by: Some("DoNotAmplifyOonDropRule"), + }, Case { name: "malicious_url_label_drops_oon", level: TimelineHomeRecommendations,