diff --git a/home-mixer/candidate_hydrators/bidirectional_follow_hydrator.rs b/home-mixer/candidate_hydrators/bidirectional_follow_hydrator.rs index 139effea..97aed7a6 100644 --- a/home-mixer/candidate_hydrators/bidirectional_follow_hydrator.rs +++ b/home-mixer/candidate_hydrators/bidirectional_follow_hydrator.rs @@ -30,10 +30,12 @@ impl Hydrator 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 = 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::>() .into_iter() .collect(); @@ -54,6 +56,9 @@ impl Hydrator 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), @@ -67,8 +72,10 @@ impl Hydrator 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; + } } } @@ -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]); diff --git a/home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs b/home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs index e8c9c397..26405e85 100644 --- a/home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs +++ b/home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs @@ -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>> = 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), diff --git a/visibility-filtering/hydration/viewer_hydrator.rs b/visibility-filtering/hydration/viewer_hydrator.rs index 51b9b3bd..fad051df 100644 --- a/visibility-filtering/hydration/viewer_hydrator.rs +++ b/visibility-filtering/hydration/viewer_hydrator.rs @@ -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, @@ -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, 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::*; @@ -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( diff --git a/visibility-filtering/models/viewer.rs b/visibility-filtering/models/viewer.rs index 46a9b6b0..2ee8cfe0 100644 --- a/visibility-filtering/models/viewer.rs +++ b/visibility-filtering/models/viewer.rs @@ -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 { @@ -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()); + } +} diff --git a/visibility-filtering/rules/tweet_rules.rs b/visibility-filtering/rules/tweet_rules.rs index 15db052e..77ebf20a 100644 --- a/visibility-filtering/rules/tweet_rules.rs +++ b/visibility-filtering/rules/tweet_rules.rs @@ -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); @@ -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,