Skip to content
Closed
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
42 changes: 39 additions & 3 deletions home-mixer/candidate_hydrators/bidirectional_follow_hydrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ impl Hydrator<ScoredPostsQuery, PostCandidate> for BidirectionalFollowHydrator {
.collect();
let check_all_authors = query.params.get(EnableAllAuthorFollowHydration);

// CoreData fills author_id. A 0 here is unhydrated, not user 0 — do not
// query socialgraph for it or stamp false mutual-follow on the row.
let authors: Vec<u64> = candidates
.iter()
.map(|c| c.author_id)
.filter(|a| check_all_authors || following.contains(&(*a as i64)))
.filter(|a| *a != 0 && (check_all_authors || following.contains(&(*a as i64))))
.collect::<HashSet<_>>()
.into_iter()
.collect();
Expand All @@ -54,6 +56,9 @@ impl Hydrator<ScoredPostsQuery, PostCandidate> for BidirectionalFollowHydrator {
candidates
.iter()
.map(|c| {
if c.author_id == 0 {
return Ok(PostCandidate::default());
}
let author_follows_viewer = followers.contains(&c.author_id);
Ok(PostCandidate {
author_follows_viewer: check_all_authors.then_some(author_follows_viewer),
Expand All @@ -67,8 +72,10 @@ impl Hydrator<ScoredPostsQuery, PostCandidate> for BidirectionalFollowHydrator {
}

fn update(&self, candidate: &mut PostCandidate, hydrated: PostCandidate) {
candidate.is_mutual_follow_author = hydrated.is_mutual_follow_author;
candidate.author_follows_viewer = hydrated.author_follows_viewer;
if hydrated.is_mutual_follow_author.is_some() {
candidate.is_mutual_follow_author = hydrated.is_mutual_follow_author;
candidate.author_follows_viewer = hydrated.author_follows_viewer;
}
}
}

Expand Down Expand Up @@ -184,6 +191,35 @@ mod tests {
);
}

#[tokio::test]
async fn unhydrated_author_id_is_not_stamped() {
let hydrator = hydrator(vec![0, 2]);
let q = query(vec![0, 2], true);
let candidates = vec![candidate(0), candidate(2)];

let out = hydrator.hydrate(&q, &candidates).await;

assert_eq!(out[0].as_ref().unwrap().is_mutual_follow_author, None);
assert_eq!(out[0].as_ref().unwrap().author_follows_viewer, None);
assert_eq!(out[1].as_ref().unwrap().is_mutual_follow_author, Some(true));
}

#[test]
fn update_skips_unhydrated_author_stamp() {
let hydrator = hydrator(vec![]);
let mut candidate = candidate(2);
candidate.is_mutual_follow_author = Some(true);
hydrator.update(
&mut candidate,
PostCandidate {
is_mutual_follow_author: None,
author_follows_viewer: None,
..Default::default()
},
);
assert_eq!(candidate.is_mutual_follow_author, Some(true));
}

#[tokio::test]
async fn empty_following_tags_all_false() {
let hydrator = hydrator(vec![1, 2, 3]);
Expand Down
5 changes: 4 additions & 1 deletion home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,12 +324,15 @@ impl PhoenixCandidatePipeline {
cached_posts_source,
];

// BidirectionalFollow reads author_id. It has to run after CoreData or
// TweetMixer rows (author_id = 0) get a false mutual-follow stamp that
// TES never overwrites. InNetwork-before-CoreData is a separate leftover.
let hydrators: Vec<Box<dyn Hydrator<ScoredPostsQuery, PostCandidate>>> = vec![
Box::new(InNetworkCandidateHydrator),
Box::new(core_data_hydrator),
Box::new(BidirectionalFollowHydrator {
socialgraph_client: socialgraph_client.clone(),
}),
Box::new(core_data_hydrator),
Box::new(QuoteHydrator::new(tes_client.clone(), socialgraph_client.clone()).await),
Box::new(MediaInfoHydrator::new(media_info_cache_client).await),
Box::new(SubscriptionHydrator::new(tes_client.clone()).await),
Expand Down
60 changes: 55 additions & 5 deletions visibility-filtering/hydration/viewer_hydrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,7 @@ impl ViewerHydrator {
);
match result {
Ok(Ok(data)) => {
let age = match data.age_in_years {
Some(age) => ViewerAge::Known(age),
None if data.user_exists => ViewerAge::NotStated,
None => ViewerAge::Unknown,
};
let age = classify_viewer_age(data.age_in_years, data.user_exists);
(
data.nsfw_view.unwrap_or(false),
age,
Expand Down Expand Up @@ -83,6 +79,20 @@ impl ViewerHydrator {
}
}

/// Gizmoduck `age_in_years` uses `0` (and other non-positives) as "no usable
/// birthday", the same sentinel home-mixer / Phoenix already treat as missing.
/// Mapping that to `Known(0)` makes `viewer_is_underage` true worldwide and
/// hard-drops all sensitive media. Missing / invalid age on an existing user
/// is `NotStated` (jurisdiction-scoped). A failed existence check stays
/// `Unknown` (fail open), matching RPC error / timeout.
pub(crate) fn classify_viewer_age(age_in_years: Option<i32>, user_exists: bool) -> ViewerAge {
match age_in_years {
Some(age) if age > 0 => ViewerAge::Known(age),
_ if user_exists => ViewerAge::NotStated,
_ => ViewerAge::Unknown,
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -230,6 +240,46 @@ mod tests {
assert_eq!(viewer.country_code.as_deref(), Some("us"));
}

#[test]
fn zero_age_on_existing_user_is_not_stated() {
assert_eq!(classify_viewer_age(Some(0), true), ViewerAge::NotStated);
assert_eq!(classify_viewer_age(Some(-3), true), ViewerAge::NotStated);
assert_eq!(classify_viewer_age(None, true), ViewerAge::NotStated);
}

#[test]
fn zero_age_on_missing_user_is_unknown() {
assert_eq!(classify_viewer_age(Some(0), false), ViewerAge::Unknown);
assert_eq!(classify_viewer_age(None, false), ViewerAge::Unknown);
}

#[test]
fn positive_age_is_known() {
assert_eq!(classify_viewer_age(Some(15), true), ViewerAge::Known(15));
assert_eq!(classify_viewer_age(Some(18), false), ViewerAge::Known(18));
}

#[tokio::test]
async fn gizmoduck_zero_age_is_not_stated() {
let hydrator = hydrator_with_viewer_data(
123,
ViewerData {
user_exists: true,
nsfw_view: Some(false),
age_in_years: Some(0),
..Default::default()
},
);

let viewer = hydrator
.hydrate(Some(123), Some("US".to_string()), SafetyLevel::FilterAll)
.await;

assert_eq!(viewer.viewer_age, ViewerAge::NotStated);
assert!(!viewer.viewer_is_underage());
assert!(viewer.viewer_has_no_stated_age());
}

#[tokio::test]
async fn existing_viewer_without_age_is_not_stated() {
let hydrator = hydrator_with_viewer_data(
Expand Down
54 changes: 53 additions & 1 deletion visibility-filtering/models/viewer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ pub struct ViewerFeatures {
}

impl ViewerFeatures {
/// Confirmed calendar age in `[1, 18)`. `0` and negatives are sentinels, not a child.
pub fn viewer_is_underage(&self) -> bool {
matches!(self.viewer_age, ViewerAge::Known(age) if age < ADULT_AGE_YEARS)
matches!(self.viewer, Viewer::LoggedIn(_))
&& matches!(self.viewer_age, ViewerAge::Known(age) if (1..ADULT_AGE_YEARS).contains(&age))
}

pub fn viewer_has_no_stated_age(&self) -> bool {
Expand All @@ -52,3 +54,53 @@ impl ViewerFeatures {
matches!(self.viewer, Viewer::LoggedOut)
}
}

#[cfg(test)]
mod tests {
use super::*;

fn logged_in(age: ViewerAge) -> ViewerFeatures {
ViewerFeatures {
viewer: Viewer::LoggedIn(1),
viewer_age: age,
..Default::default()
}
}

#[test]
fn known_zero_is_not_underage() {
let v = logged_in(ViewerAge::Known(0));
assert!(!v.viewer_is_underage());
assert!(!v.viewer_has_no_stated_age());
}

#[test]
fn negative_known_age_is_not_underage() {
assert!(!logged_in(ViewerAge::Known(-1)).viewer_is_underage());
}

#[test]
fn fifteen_is_underage_eighteen_is_not() {
assert!(logged_in(ViewerAge::Known(15)).viewer_is_underage());
assert!(logged_in(ViewerAge::Known(17)).viewer_is_underage());
assert!(!logged_in(ViewerAge::Known(18)).viewer_is_underage());
}

#[test]
fn logged_out_known_age_is_not_underage() {
let v = ViewerFeatures {
viewer: Viewer::LoggedOut,
viewer_age: ViewerAge::Known(15),
..Default::default()
};
assert!(!v.viewer_is_underage());
assert!(v.viewer_is_logged_out());
}

#[test]
fn unknown_and_not_stated_are_not_underage() {
assert!(!logged_in(ViewerAge::Unknown).viewer_is_underage());
assert!(!logged_in(ViewerAge::NotStated).viewer_is_underage());
assert!(logged_in(ViewerAge::NotStated).viewer_has_no_stated_age());
}
}
16 changes: 16 additions & 0 deletions visibility-filtering/rules/tweet_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,9 @@ mod tests {

assert_allows(underage, &gating_viewer(ViewerAge::Known(18)), &hp);
assert_allows(underage, &gating_viewer(ViewerAge::Known(18)), &text);
// gizmoduck age 0 is a missing-birthday sentinel, not a confirmed infant.
assert_allows(underage, &gating_viewer(ViewerAge::Known(0)), &hp);
assert_allows(underage, &gating_viewer(ViewerAge::Known(0)), &text);
assert_allows(underage, &gating_viewer(ViewerAge::Unknown), &hp);
assert_allows(no_age, &gating_viewer(ViewerAge::Unknown), &hp);
assert_allows(underage, &gating_viewer(ViewerAge::Unknown), &text);
Expand Down Expand Up @@ -1044,6 +1047,19 @@ mod tests {
assert_drops(no_age, &request_fallback, &hp, &reason);
}

#[test]
fn zero_known_age_does_not_underage_drop_outside_gating_country() {
let underage = sensitive_spec("SensitiveViewerUnderageDropRule");
let no_age = sensitive_spec("SensitiveViewerNoStatedAgeDropRule");
let hp = media_label(SafetyLabelType::NSFW_HIGH_PRECISION);
let us = ViewerFeatures {
country_code: Some("us".into()),
..gating_viewer(ViewerAge::Known(0))
};
assert_allows(underage, &us, &hp);
assert_allows(no_age, &us, &hp);
}

fn all_rule_slices() -> [&'static [RuleSpec]; 15] {
use crate::rules::author_rules::{
AUTHOR_STATE_DROPS, OON_NSFW_AUTHOR_DROPS, OON_USER_LABEL_DROPS, SOCIALGRAPH_DROPS,
Expand Down