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
107 changes: 104 additions & 3 deletions home-mixer/candidate_hydrators/vf_candidate_hydrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<FilteredReason>>)>,
) -> HashMap<u64, Result<Option<FilteredReason>>> {
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(&quote_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(&quote_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(&quote_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(&quote_of(10), &err));

let missing = results(vec![(1, Ok(None))]);
assert!(!should_drop_ancillary(&quote_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));
}
}
55 changes: 54 additions & 1 deletion home-mixer/filters/vf_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,26 @@ impl Filter<ScoredPostsQuery, PostCandidate> 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<FilteredReason>) -> 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,
Expand Down Expand Up @@ -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);
}
}