diff --git a/home-mixer/candidate_hydrators/vf_candidate_hydrator.rs b/home-mixer/candidate_hydrators/vf_candidate_hydrator.rs index b6554ed6..8fa02358 100644 --- a/home-mixer/candidate_hydrators/vf_candidate_hydrator.rs +++ b/home-mixer/candidate_hydrators/vf_candidate_hydrator.rs @@ -174,11 +174,112 @@ pub(crate) fn should_drop_ancillary( false } +/// Soft VF verdicts on a quoted / ancestor / retweeted child must drop the +/// wrapper. Home Mixer has no interstitial, tombstone, or avoid chrome for +/// an embedded post, so keeping the card shows content soft-policy hid. fn should_drop_reason(reason: &FilteredReason) -> bool { match reason { - FilteredReason::SafetyResult(safety_result) => { - matches!(safety_result.action, Action::Drop(_)) + FilteredReason::SafetyResult(safety_result) => match safety_result.action { + Action::Allow | Action::Downrank => false, + Action::Drop(_) + | Action::Avoid + | Action::Tombstone + | Action::Interstitial + | Action::NotEvaluated => true, + }, + _ => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn safety(action: Action) -> FilteredReason { + FilteredReason::SafetyResult(xai_visibility_filtering::models::SafetyResult { + action, + ..Default::default() + }) + } + + fn results( + entries: Vec<(u64, Result>)>, + ) -> HashMap>> { + entries.into_iter().collect() + } + + fn quote_of(child_id: u64) -> PostCandidate { + PostCandidate { + tweet_id: 1, + quoted_tweet_id: Some(child_id), + ..Default::default() + } + } + + #[test] + fn ancillary_soft_hide_drops_quote() { + for action in [ + Action::Avoid, + Action::Tombstone, + Action::Interstitial, + Action::NotEvaluated, + Action::Drop(Default::default()), + ] { + let vf = results(vec![(1, Ok(None)), (10, Ok(Some(safety(action))))]); + assert!( + should_drop_ancillary("e_of(10), &vf), + "ancillary {action:?} must drop the wrapper" + ); + } + } + + #[test] + fn ancillary_allow_and_downrank_keep_quote() { + for action in [Action::Allow, Action::Downrank] { + let vf = results(vec![(1, Ok(None)), (10, Ok(Some(safety(action))))]); + assert!( + !should_drop_ancillary("e_of(10), &vf), + "ancillary {action:?} must not drop the wrapper" + ); } - _ => true, + + let allow_none = results(vec![(1, Ok(None)), (10, Ok(None))]); + assert!(!should_drop_ancillary("e_of(10), &allow_none)); + } + + #[test] + fn ancillary_err_or_missing_still_fail_open_here() { + // Primary/ancillary Err+miss is #121. This class is soft verdicts. + let err = results(vec![(1, Ok(None)), (10, Err(anyhow::anyhow!("vf down")))]); + assert!(!should_drop_ancillary("e_of(10), &err)); + + let missing = results(vec![(1, Ok(None))]); + assert!(!should_drop_ancillary("e_of(10), &missing)); + } + + #[test] + fn interstitial_on_ancestor_drops_reply() { + let vf = results(vec![ + (1, Ok(None)), + (10, Ok(Some(safety(Action::Interstitial)))), + ]); + let reply = PostCandidate { + tweet_id: 1, + ancestors: vec![10], + ..Default::default() + }; + assert!(should_drop_ancillary(&reply, &vf)); + } + + #[test] + fn tombstoned_ancestor_soft_hide_is_still_skipped() { + let vf = results(vec![(10, Ok(Some(safety(Action::Interstitial))))]); + let reply = PostCandidate { + tweet_id: 1, + ancestors: vec![10], + tombstone_ancestor_ids: vec![10], + ..Default::default() + }; + assert!(!should_drop_ancillary(&reply, &vf)); } } diff --git a/home-mixer/filters/vf_filter.rs b/home-mixer/filters/vf_filter.rs index f16e3e62..94e0184b 100644 --- a/home-mixer/filters/vf_filter.rs +++ b/home-mixer/filters/vf_filter.rs @@ -19,10 +19,26 @@ impl Filter for VFFilter { } } +/// Home Mixer cannot render VF's soft hide actions. `Avoid` is Strato's +/// hide-from-timeline / suppress verdict (the sample +/// `homeMixerFilteredReason.Tweet` payload decodes to it). `Tombstone` +/// replaces the post. `NotEvaluated` is a missing action on an otherwise +/// present `SafetyResult` — fail closed, same as a None action. +/// +/// `Interstitial` stays visible here: TimelineHome NSFW is an interstitial +/// for in-network posts. Ancillary wrappers drop it in +/// `should_drop_reason` because the quote/reply card has no warning UI. +fn safety_action_hides_from_timeline(action: &Action) -> bool { + match action { + Action::Allow | Action::Interstitial | Action::Downrank => false, + Action::Drop(_) | Action::Avoid | Action::Tombstone | Action::NotEvaluated => true, + } +} + fn should_drop(reason: &Option) -> bool { match reason { Some(FilteredReason::SafetyResult(safety_result)) => { - matches!(safety_result.action, Action::Drop(_)) + safety_action_hides_from_timeline(&safety_result.action) } Some(_) => true, None => false, @@ -95,4 +111,41 @@ mod tests { assert_eq!(result.removed.len(), 1); assert_eq!(result.kept.len(), 1); } + + fn safety(action: Action) -> FilteredReason { + FilteredReason::SafetyResult(xai_visibility_filtering::models::SafetyResult { + action, + ..Default::default() + }) + } + + #[tokio::test] + async fn drops_avoid_tombstone_and_not_evaluated() { + let filter = VFFilter; + let query = ScoredPostsQuery::default(); + + let result = filter.filter( + &query, + vec![ + candidate_with_reason(Some(safety(Action::Avoid))), + candidate_with_reason(Some(safety(Action::Tombstone))), + candidate_with_reason(Some(safety(Action::NotEvaluated))), + candidate_with_reason(Some(safety(Action::Interstitial))), + candidate_with_reason(Some(safety(Action::Downrank))), + candidate_with_reason(None), + ], + ); + + assert_eq!(result.removed.len(), 3); + assert_eq!(result.kept.len(), 3); + assert!(matches!( + result.kept[0].visibility_reason, + Some(FilteredReason::SafetyResult(ref sr)) if sr.action == Action::Interstitial + )); + assert!(matches!( + result.kept[1].visibility_reason, + Some(FilteredReason::SafetyResult(ref sr)) if sr.action == Action::Downrank + )); + assert_eq!(result.kept[2].visibility_reason, None); + } }