diff --git a/home-mixer/candidate_hydrators/core_data_candidate_hydrator.rs b/home-mixer/candidate_hydrators/core_data_candidate_hydrator.rs index fdf9c1d6..e01bef9a 100644 --- a/home-mixer/candidate_hydrators/core_data_candidate_hydrator.rs +++ b/home-mixer/candidate_hydrators/core_data_candidate_hydrator.rs @@ -104,6 +104,8 @@ impl CachedHydrator for CoreDataCandidateHydrat } Some(Ok(None)) | None => { missing_count += 1; + // Ok(default) so CachedHydrator can negative-cache the miss. + // update() must not apply this payload — it would wipe Thunder/Phoenix ids. hydrated_candidates.push(Ok(PostCandidate::default())); } Some(Err(err)) => { @@ -118,6 +120,11 @@ impl CachedHydrator for CoreDataCandidateHydrat } fn update(&self, candidate: &mut PostCandidate, hydrated: PostCandidate) { + // TES miss is cached as Default. Applying it would clear source-populated + // retweet/reply ids. A real TES hit always carries author_id. + if is_tes_miss_payload(&hydrated) { + return; + } if candidate.author_id == 0 && hydrated.author_id != 0 { candidate.author_id = hydrated.author_id; } @@ -129,6 +136,15 @@ impl CachedHydrator for CoreDataCandidateHydrat } } +fn is_tes_miss_payload(hydrated: &PostCandidate) -> bool { + hydrated.author_id == 0 + && hydrated.tweet_text.is_empty() + && hydrated.retweeted_tweet_id.is_none() + && hydrated.retweeted_user_id.is_none() + && hydrated.in_reply_to_tweet_id.is_none() + && hydrated.ancestor_users.is_empty() +} + fn core_data_fetch_ids(candidates: &[PostCandidate]) -> Vec { let mut fetch_ids: Vec = candidates.iter().map(|c| c.tweet_id).collect(); fetch_ids.extend( @@ -178,3 +194,79 @@ impl CoreDataCandidateHydrator { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::clients::tweet_entity_service_client::{MockTESClient, TESClient}; + use std::sync::Arc; + use xai_candidate_pipeline::hydrator::Hydrator; + + fn thunder_retweet() -> PostCandidate { + PostCandidate { + tweet_id: 10, + author_id: 100, + retweeted_tweet_id: Some(99), + retweeted_user_id: Some(200), + in_reply_to_tweet_id: Some(50), + tweet_text: String::new(), + ..Default::default() + } + } + + #[tokio::test] + async fn tes_miss_payload_does_not_clear_source_graph_ids() { + let hydrator = CoreDataCandidateHydrator::new(Arc::new(MockTESClient::default()) + as Arc) + .await; + let mut candidate = thunder_retweet(); + Hydrator::update(&hydrator, &mut candidate, PostCandidate::default()); + assert_eq!(candidate.retweeted_tweet_id, Some(99)); + assert_eq!(candidate.retweeted_user_id, Some(200)); + assert_eq!(candidate.in_reply_to_tweet_id, Some(50)); + assert_eq!(candidate.author_id, 100); + } + + #[tokio::test] + async fn tes_success_fills_and_can_clear_graph_fields() { + let hydrator = CoreDataCandidateHydrator::new(Arc::new(MockTESClient::default()) + as Arc) + .await; + let mut candidate = thunder_retweet(); + Hydrator::update( + &hydrator, + &mut candidate, + PostCandidate { + author_id: 7, + retweeted_tweet_id: None, + retweeted_user_id: None, + in_reply_to_tweet_id: None, + tweet_text: "original".to_string(), + ancestor_users: vec![], + ..Default::default() + }, + ); + assert_eq!(candidate.author_id, 100); + assert_eq!(candidate.retweeted_tweet_id, None); + assert_eq!(candidate.retweeted_user_id, None); + assert_eq!(candidate.in_reply_to_tweet_id, None); + assert_eq!(candidate.tweet_text, "original"); + } + + #[tokio::test] + async fn tes_miss_hydrate_leaves_thunder_retweet_ids() { + let hydrator = CoreDataCandidateHydrator::new(Arc::new(MockTESClient::default()) + as Arc) + .await; + let mut candidates = vec![thunder_retweet()]; + let hydrated = hydrator + .hydrate(&ScoredPostsQuery::default(), &candidates) + .await; + assert!(hydrated[0].is_ok(), "miss stays Ok so it can be negative-cached"); + hydrator.update_all(&mut candidates, hydrated); + assert_eq!(candidates[0].retweeted_tweet_id, Some(99)); + assert_eq!(candidates[0].retweeted_user_id, Some(200)); + assert_eq!(candidates[0].in_reply_to_tweet_id, Some(50)); + assert_eq!(candidates[0].author_id, 100); + } +}