diff --git a/home-mixer/candidate_hydrators/filtered_topics_hydrator.rs b/home-mixer/candidate_hydrators/filtered_topics_hydrator.rs index 32db8d9b..0aa6437c 100644 --- a/home-mixer/candidate_hydrators/filtered_topics_hydrator.rs +++ b/home-mixer/candidate_hydrators/filtered_topics_hydrator.rs @@ -22,14 +22,24 @@ fn decode_topics_pair( match decoded { StratoResult::Ok(v) => { let ft = v.v; - let exp_topics = ft - .as_ref() - .and_then(|ft| ft.topic_ids_for_experiment(experiment).cloned()); + // Some([]) = lookup succeeded with no topics. None is + // reserved for fetch/decode failure so Explore can fail closed. + let exp_topics = Some( + ft.as_ref() + .and_then(|ft| ft.topic_ids_for_experiment(experiment).cloned()) + .unwrap_or_default(), + ); let unf_topics = if need_unfiltered { - ft.as_ref().and_then(|ft| { - ft.topic_ids_for_experiment(TopicFilteringExperiment::Unfiltered) - .cloned() - }) + Some( + ft.as_ref() + .and_then(|ft| { + ft.topic_ids_for_experiment( + TopicFilteringExperiment::Unfiltered, + ) + .cloned() + }) + .unwrap_or_default(), + ) } else { None }; diff --git a/home-mixer/filters/topic_ids_filter.rs b/home-mixer/filters/topic_ids_filter.rs index 317a8de3..0e411313 100644 --- a/home-mixer/filters/topic_ids_filter.rs +++ b/home-mixer/filters/topic_ids_filter.rs @@ -29,6 +29,11 @@ impl Filter for TopicIdsFilter { let all_topic_ids = TopicIdExpansion::all_production_topic_ids(); let excluded: HashSet = all_topic_ids.difference(&expanded).copied().collect(); + // Bulk Explore is an exclusion filter: keep posts whose known + // topics are not all outside the requested set. Missing topic + // hydration (None) must fail closed — otherwise withheld + // Sports/Politics/etc. posts still rank when Strato misses. + // Some([]) is a successful lookup with no topics (untagged). let (kept, removed): (Vec<_>, Vec<_>) = candidates .into_iter() @@ -36,7 +41,8 @@ impl Filter for TopicIdsFilter { Some(candidate_topics) if !candidate_topics.is_empty() => { !candidate_topics.iter().all(|tid| excluded.contains(tid)) } - _ => true, + Some(_) => true, + None => false, }); (kept, removed) } else { @@ -881,8 +887,56 @@ mod tests { let result = TopicIdsFilter.filter(&query, candidates); let kept_ids: Vec = result.kept.iter().map(|c| c.tweet_id).collect(); let removed_ids: Vec = result.removed.iter().map(|c| c.tweet_id).collect(); - assert_eq!(kept_ids, vec![1, 3, 4, 6, 7]); - assert_eq!(removed_ids, vec![2, 5]); + assert_eq!(kept_ids, vec![1, 4, 6, 7]); + assert_eq!(removed_ids, vec![2, 3, 5]); + } + + #[test] + fn test_bulk_explore_drops_hydration_miss_not_known_untagged() { + let query = ScoredPostsQuery { + topic_ids: vec![ + TopicIdExpansion::XAI_NEWS, + TopicIdExpansion::BUSINESS_FINANCE, + TopicIdExpansion::SCIENCE_TECHNOLOGY, + TopicIdExpansion::XAI_MOVIES_TV, + TopicIdExpansion::XAI_AI, + TopicIdExpansion::XAI_GAMING, + TopicIdExpansion::XAI_CRYPTOCURRENCY, + ], + ..Default::default() + }; + + let candidates = vec![ + PostCandidate { + tweet_id: 1, + filtered_topic_ids: None, + ..Default::default() + }, + PostCandidate { + tweet_id: 2, + filtered_topic_ids: Some(vec![]), + ..Default::default() + }, + PostCandidate { + tweet_id: 3, + filtered_topic_ids: Some(vec![TopicIdExpansion::XAI_SPORTS_REAL]), + ..Default::default() + }, + ]; + + let result = TopicIdsFilter.filter(&query, candidates); + let kept_ids: Vec = result.kept.iter().map(|c| c.tweet_id).collect(); + let removed_ids: Vec = result.removed.iter().map(|c| c.tweet_id).collect(); + assert_eq!( + kept_ids, + vec![2], + "known-empty topic list is untagged For You filler" + ); + assert_eq!( + removed_ids, + vec![1, 3], + "hydration miss and withheld Sports must not rank on Explore" + ); } #[test]