From 6ffcde60b53528a2b5603446c0932cecb172cb84 Mon Sep 17 00:00:00 2001 From: Jon Bailey <297513015+Pitchfork-and-Torch@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:12:55 -0400 Subject: [PATCH] home-mixer: stamp in_network after TES fills author_id For You was classifying follow-graph membership before CoreData wrote author_id. TweetMixer often ships 0; that stamped OON and Recs-only VF drops then hit followed authors. --- .../in_network_candidate_hydrator.rs | 76 +++++++++++++++++++ .../phoenix_candidate_pipeline.rs | 7 +- .../phoenix_scores_pipeline.rs | 1 + home-mixer/sources/tweet_mixer_source.rs | 1 + 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/home-mixer/candidate_hydrators/in_network_candidate_hydrator.rs b/home-mixer/candidate_hydrators/in_network_candidate_hydrator.rs index fba4caadd..74403aba0 100644 --- a/home-mixer/candidate_hydrators/in_network_candidate_hydrator.rs +++ b/home-mixer/candidate_hydrators/in_network_candidate_hydrator.rs @@ -43,3 +43,79 @@ impl Hydrator for InNetworkCandidateHydrator { candidate.in_network = hydrated.in_network; } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::user_features::UserFeatures; + + fn query_following(viewer_id: u64, followed: Vec) -> ScoredPostsQuery { + ScoredPostsQuery { + user_id: viewer_id, + user_features: UserFeatures { + followed_user_ids: followed, + ..Default::default() + }, + ..Default::default() + } + } + + async fn stamp(query: &ScoredPostsQuery, author_id: u64) -> Option { + let hydrator = InNetworkCandidateHydrator; + let candidates = vec![PostCandidate { + tweet_id: 1, + author_id, + ..Default::default() + }]; + let hydrated = hydrator.hydrate(query, &candidates).await; + let mut candidate = candidates.into_iter().next().unwrap(); + hydrator.update(&mut candidate, hydrated.into_iter().next().unwrap().unwrap()); + candidate.in_network + } + + #[tokio::test] + async fn followed_author_is_in_network() { + let query = query_following(1, vec![42]); + assert_eq!(stamp(&query, 42).await, Some(true)); + } + + #[tokio::test] + async fn unfollowed_author_is_oon() { + let query = query_following(1, vec![42]); + assert_eq!(stamp(&query, 99).await, Some(false)); + } + + #[tokio::test] + async fn missing_author_id_is_stamped_oon() { + let query = query_following(1, vec![42]); + assert_eq!( + stamp(&query, 0).await, + Some(false), + "author_id 0 is not in the follow set, so this stamps OON" + ); + } + + #[tokio::test] + async fn tes_author_id_must_be_present_before_stamp() { + let query = query_following(1, vec![42]); + let hydrator = InNetworkCandidateHydrator; + + // Wrong order: stamp while author_id is still 0, then TES fills the followed author. + let mut too_early = PostCandidate { + tweet_id: 1, + author_id: 0, + ..Default::default() + }; + let early = hydrator.hydrate(&query, &[too_early.clone()]).await; + hydrator.update(&mut too_early, early.into_iter().next().unwrap().unwrap()); + too_early.author_id = 42; + assert_eq!( + too_early.in_network, + Some(false), + "stale OON stamp after TES fills a followed author" + ); + + // Right order: TES author_id first. + assert_eq!(stamp(&query, 42).await, Some(true)); + } +} diff --git a/home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs b/home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs index c508b4468..5779ed4c1 100644 --- a/home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs +++ b/home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs @@ -327,12 +327,17 @@ impl PhoenixCandidatePipeline { cached_posts_source, ]; + // CoreData must run before InNetwork. TweetMixer (and other thin sources) often + // ship author_id = 0; TES fills it. InNetwork reads author_id and stamps + // in_network forever. Stamping first marks followed authors as OON, so Recs-only + // VF drops (DoNotAmplify, NSFW/spam high-recall, malicious URL) fire on Home + // authors. PhoenixScoresPipeline already uses this order. let hydrators: Vec>> = vec![ - Box::new(InNetworkCandidateHydrator), Box::new(BidirectionalFollowHydrator { socialgraph_client: socialgraph_client.clone(), }), Box::new(core_data_hydrator), + Box::new(InNetworkCandidateHydrator), Box::new( QuoteHydrator::new( tes_client.clone(), diff --git a/home-mixer/candidate_pipeline/phoenix_scores_pipeline.rs b/home-mixer/candidate_pipeline/phoenix_scores_pipeline.rs index 531f531bf..e31c868bc 100644 --- a/home-mixer/candidate_pipeline/phoenix_scores_pipeline.rs +++ b/home-mixer/candidate_pipeline/phoenix_scores_pipeline.rs @@ -135,6 +135,7 @@ impl PhoenixScoresPipeline { let sources: Vec>> = vec![Box::new(SeedCandidatesSource)]; + // TES author_id before InNetwork — do not invert. See phoenix_candidate_pipeline. let hydrators: Vec>> = vec![ Box::new(CoreDataCandidateHydrator::new(tes_client.clone()).await), Box::new(InNetworkCandidateHydrator), diff --git a/home-mixer/sources/tweet_mixer_source.rs b/home-mixer/sources/tweet_mixer_source.rs index 743270272..81d584933 100644 --- a/home-mixer/sources/tweet_mixer_source.rs +++ b/home-mixer/sources/tweet_mixer_source.rs @@ -84,6 +84,7 @@ impl Source for TweetMixerSource { return None; } + // 0 until CoreData/TES. InNetwork must run after that fill. let author_id = candidate .author_id .and_then(|id| u64::try_from(id).ok())