diff --git a/visibility-filtering/hydration/tes_hydrator.rs b/visibility-filtering/hydration/tes_hydrator.rs index 7b18980a..929f4539 100644 --- a/visibility-filtering/hydration/tes_hydrator.rs +++ b/visibility-filtering/hydration/tes_hydrator.rs @@ -1,4 +1,4 @@ -use crate::hydration::batch::TweetHydrationBatch; +use crate::hydration::batch::{Hydrated, TweetHydrationBatch}; use crate::hydration::metrics::{record_batch_size, timed_keyed_rpc, timed_results}; use crate::models::{ CoreFeature, MediaFeature, NsfwFeature, TweetCandidateInput, TweetFeatures, TweetId, @@ -183,6 +183,10 @@ fn build_tweet_features( admin: tweet_keyed.nsfw_admin.get(&id).copied().unwrap_or(false), }; let edit_control = tweet_keyed.edit_control.get(&id).cloned(); + let edit_control_lookup_failed = matches!( + tweet_keyed.edit_control.hydrated(&id), + Some(Hydrated::Failed(_)) + ); core_datas .get(&tweet_id) @@ -197,8 +201,12 @@ fn build_tweet_features( is_nullcast, is_community_tweet, edit_control, + edit_control_lookup_failed, + }) + .unwrap_or(TweetFeatures { + edit_control_lookup_failed, + ..Default::default() }) - .unwrap_or_default() } fn media_feature(entities: MediaEntities) -> MediaFeature { @@ -474,4 +482,115 @@ mod tests { assert!(f.core.text.is_empty()); assert!(!f.media.has_media); } + + fn not_found_edit(id: u64) -> TweetHydrationBatch { + TweetHydrationBatch::from_results( + [TweetId(id)], + HashMap::from([(TweetId(id), Ok::<_, anyhow::Error>(None))]), + ) + } + + fn failed_edit(id: u64) -> TweetHydrationBatch { + TweetHydrationBatch::from_results( + [TweetId(id)], + HashMap::from([( + TweetId(id), + Err::, _>("tes unavailable"), + )]), + ) + } + + fn current_edit(id: u64) -> TweetHydrationBatch { + found( + id, + EditControl::Initial(xai_core_entities::entities::EditControlInitial { + edit_tweet_ids: vec![id], + ..Default::default() + }), + ) + } + + #[test] + fn assemble_stamps_edit_control_lookup_failed_on_tes_err() { + let candidates = vec![candidate(10, 100)]; + let core_datas = HashMap::from([( + TweetId(10), + PureCoreData { + author_id: 100, + ..Default::default() + }, + )]); + let tweet_keyed = TweetHydration { + edit_control: failed_edit(10), + ..Default::default() + }; + + let features = hydrator().assemble_tweet_features(&candidates, &core_datas, &tweet_keyed); + + assert!(features[&TweetId(10)].edit_control_lookup_failed); + assert!(features[&TweetId(10)].edit_control.is_none()); + } + + #[test] + fn assemble_does_not_stamp_edit_control_lookup_failed_on_not_found() { + let candidates = vec![candidate(10, 100)]; + let core_datas = HashMap::from([( + TweetId(10), + PureCoreData { + author_id: 100, + ..Default::default() + }, + )]); + let tweet_keyed = TweetHydration { + edit_control: not_found_edit(10), + ..Default::default() + }; + + let features = hydrator().assemble_tweet_features(&candidates, &core_datas, &tweet_keyed); + + assert!(!features[&TweetId(10)].edit_control_lookup_failed); + assert!(features[&TweetId(10)].edit_control.is_none()); + } + + #[test] + fn assemble_keeps_found_edit_control_and_does_not_fail() { + let candidates = vec![candidate(10, 100)]; + let core_datas = HashMap::from([( + TweetId(10), + PureCoreData { + author_id: 100, + ..Default::default() + }, + )]); + let tweet_keyed = TweetHydration { + edit_control: current_edit(10), + ..Default::default() + }; + + let features = hydrator().assemble_tweet_features(&candidates, &core_datas, &tweet_keyed); + + assert!(!features[&TweetId(10)].edit_control_lookup_failed); + assert!(features[&TweetId(10)].edit_control.is_some()); + } + + #[test] + fn assemble_stamps_edit_control_lookup_failed_when_core_missing() { + let candidates = vec![resolve_candidate( + &RawCandidate { + tweet_id: TweetId(10), + request_author_id: Some(100), + }, + &HashMap::new(), + ) + .unwrap()]; + let tweet_keyed = TweetHydration { + edit_control: failed_edit(10), + ..Default::default() + }; + + let features = + hydrator().assemble_tweet_features(&candidates, &HashMap::new(), &tweet_keyed); + + assert!(features[&TweetId(10)].edit_control_lookup_failed); + } } diff --git a/visibility-filtering/models/mod.rs b/visibility-filtering/models/mod.rs index b00cd656..c3cb25a7 100644 --- a/visibility-filtering/models/mod.rs +++ b/visibility-filtering/models/mod.rs @@ -119,6 +119,9 @@ impl HydratedTweetCandidate { } pub fn is_stale_tweet(&self) -> bool { + if self.tweet_features.edit_control_lookup_failed { + return true; + } let Some(ec) = &self.tweet_features.edit_control else { return false; }; @@ -127,13 +130,16 @@ impl HydratedTweetCandidate { xai_core_entities::entities::EditControl::Edit(edit) => { match &edit.edit_control_initial { Some(initial) => &initial.edit_tweet_ids, - None => return false, + // Missing initial ACL: cannot prove this version is current. + None => return true, } } }; - edit_tweet_ids - .last() - .is_some_and(|&last| last != self.tweet_id) + match edit_tweet_ids.last() { + Some(&last) => last != self.tweet_id, + // Empty chain: cannot prove this version is current. + None => true, + } } } @@ -265,6 +271,24 @@ mod tests { assert!(!candidate().is_stale_tweet()); } + #[test] + fn is_stale_tweet_when_edit_control_lookup_failed() { + let mut c = candidate(); + c.tweet_features.edit_control_lookup_failed = true; + assert!(c.is_stale_tweet()); + } + + #[test] + fn is_stale_tweet_when_edit_chain_empty() { + use xai_core_entities::entities::{EditControl, EditControlInitial}; + let mut c = candidate(); + c.tweet_features.edit_control = Some(EditControl::Initial(EditControlInitial { + edit_tweet_ids: vec![], + ..Default::default() + })); + assert!(c.is_stale_tweet()); + } + #[test] fn is_stale_tweet_edit_variant_superseded() { use xai_core_entities::entities::{EditControl, EditControlEdit, EditControlInitial}; @@ -295,6 +319,6 @@ mod tests { initial_tweet_id: 10, edit_control_initial: None, })); - assert!(!c.is_stale_tweet()); + assert!(c.is_stale_tweet()); } } diff --git a/visibility-filtering/models/tweet.rs b/visibility-filtering/models/tweet.rs index 37098848..24204a82 100644 --- a/visibility-filtering/models/tweet.rs +++ b/visibility-filtering/models/tweet.rs @@ -31,4 +31,7 @@ pub struct TweetFeatures { pub is_nullcast: bool, pub is_community_tweet: bool, pub edit_control: Option, + /// TES `get_edit_control` was Failed (timeout / RPC / missing id). + /// Distinct from `edit_control: None` (genuine NotFound: never edited). + pub edit_control_lookup_failed: bool, } diff --git a/visibility-filtering/rules/golden_corpus.rs b/visibility-filtering/rules/golden_corpus.rs index cc4da723..63fbdb7b 100644 --- a/visibility-filtering/rules/golden_corpus.rs +++ b/visibility-filtering/rules/golden_corpus.rs @@ -545,6 +545,29 @@ fn tweet_shape_cases() -> Vec { expected_action: Drop(FilteredReason::UnspecifiedReason), expected_decided_by: Some("DropStaleTweetsRule"), }, + Case { + name: "stale_edit_control_lookup_failed_drops", + level: TimelineHome, + viewer: viewer(VIEWER_ID), + candidate: tweet_candidate(|t| t.edit_control_lookup_failed = true), + expected_action: Drop(FilteredReason::UnspecifiedReason), + expected_decided_by: Some("DropStaleTweetsRule"), + }, + Case { + name: "stale_edit_control_missing_initial_drops", + level: TimelineHome, + viewer: viewer(VIEWER_ID), + candidate: tweet_candidate(|t| { + t.edit_control = Some(EditControl::Edit( + xai_core_entities::entities::EditControlEdit { + initial_tweet_id: 1, + edit_control_initial: None, + }, + )) + }), + expected_action: Drop(FilteredReason::UnspecifiedReason), + expected_decided_by: Some("DropStaleTweetsRule"), + }, Case { name: "legal_takedown_drops_in_withheld_country", level: TimelineHome, diff --git a/visibility-filtering/rules/tweet_rules.rs b/visibility-filtering/rules/tweet_rules.rs index 15db052e..c9dd2bb5 100644 --- a/visibility-filtering/rules/tweet_rules.rs +++ b/visibility-filtering/rules/tweet_rules.rs @@ -801,6 +801,27 @@ mod tests { .build(); assert_drops(stale, &viewer(VIEWER_ID), &stale_c, &reason); assert_allows(stale, &viewer(VIEWER_ID), &candidate().build()); + let failed_lookup = candidate() + .with_tweet_features(TweetFeatures { + edit_control_lookup_failed: true, + ..Default::default() + }) + .build(); + assert_drops(stale, &viewer(VIEWER_ID), &failed_lookup, &reason); + assert_drops(stale, &author_viewer(), &failed_lookup, &reason); + assert_drops(stale, &logged_out_viewer(), &failed_lookup, &reason); + let missing_initial = candidate() + .with_tweet_features(TweetFeatures { + edit_control: Some(EditControl::Edit( + xai_core_entities::entities::EditControlEdit { + initial_tweet_id: 1, + edit_control_initial: None, + }, + )), + ..Default::default() + }) + .build(); + assert_drops(stale, &viewer(VIEWER_ID), &missing_initial, &reason); let stale_rt = candidate() .with_tweet_features(TweetFeatures { edit_control: stale_edit_control(),