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
101 changes: 90 additions & 11 deletions visibility-filtering/models/safety_labels.rs
Original file line number Diff line number Diff line change
@@ -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<SafetyLabelType>);
pub struct SafetyLabelMap {
types: HashSet<SafetyLabelType>,
/// Per-type viewer scope from proto. Missing or empty = every viewer.
users: HashMap<SafetyLabelType, Vec<u64>>,
}

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

pub fn with_user_scope(mut self, label: SafetyLabelType, users: Vec<u64>) -> 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<u64>) -> 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<u64>) -> 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));
}
}
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.viewer_id())
}

#[inline]
Expand Down
18 changes: 15 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_users: HashMap::new(),
user_labels: HashSet::new(),
}
}

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

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

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