From 4b90432d8e89af2ad84b99088b1d04ce1fdaf311 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 02:37:37 +0000 Subject: [PATCH] Stop NaN/Inf Phoenix heads from poisoning For You rank One non-finite Phoenix head was used as a real feature. Dwell-regret means then went NaN and TopK kept the poisoned scores. Treat non-finite heads as missing, persist only finite ranks, and last-place the rest. Co-authored-by: Jon Bailey --- .../filters/dedup_conversation_filter.rs | 23 ++- .../scorers/phoenix_scores_ranking_scorer.rs | 5 +- home-mixer/scorers/ranking_scorer.rs | 165 ++++++++++++++++-- home-mixer/selectors/top_k_score_selector.rs | 45 ++++- 4 files changed, 216 insertions(+), 22 deletions(-) diff --git a/home-mixer/filters/dedup_conversation_filter.rs b/home-mixer/filters/dedup_conversation_filter.rs index 9e73fc2f..5a25d018 100644 --- a/home-mixer/filters/dedup_conversation_filter.rs +++ b/home-mixer/filters/dedup_conversation_filter.rs @@ -18,7 +18,10 @@ impl Filter for DedupConversationFilter { for candidate in candidates { let conversation_id = get_conversation_id(&candidate); - let score = candidate.score.unwrap_or(0.0); + let score = candidate + .score + .filter(|s| s.is_finite()) + .unwrap_or(0.0); if let Some((kept_idx, best_score)) = best_per_convo.get_mut(&conversation_id) { if score > *best_score { @@ -145,6 +148,24 @@ mod tests { assert_eq!(result.kept[0].score, Some(0.9)); } + #[tokio::test] + async fn nan_score_does_not_lock_the_conversation() { + let filter = DedupConversationFilter; + let query = ScoredPostsQuery::default(); + + let candidates = vec![ + candidate(1, vec![42], Some(f64::NAN)), + candidate(2, vec![42], Some(0.9)), + ]; + + let result = filter.filter(&query, candidates); + + assert_eq!(result.kept.len(), 1); + assert_eq!(result.removed.len(), 1); + assert_eq!(result.kept[0].tweet_id, 2); + assert_eq!(result.kept[0].score, Some(0.9)); + } + #[tokio::test] async fn keeps_higher_scored_retweet_over_reply_in_same_conversation() { let filter = DedupConversationFilter; diff --git a/home-mixer/scorers/phoenix_scores_ranking_scorer.rs b/home-mixer/scorers/phoenix_scores_ranking_scorer.rs index e0d6b0f2..8ca9a6f0 100644 --- a/home-mixer/scorers/phoenix_scores_ranking_scorer.rs +++ b/home-mixer/scorers/phoenix_scores_ranking_scorer.rs @@ -22,9 +22,10 @@ impl Scorer for PhoenixScoresRankingScorer { .iter() .map(|c| { let weighted = RankingScorer::compute_weighted_score(&weights, query, c); + let persistable = weighted.is_finite().then_some(weighted); Ok(PostCandidate { - weighted_score: Some(weighted), - score: Some(weighted), + weighted_score: persistable, + score: persistable, ..Default::default() }) }) diff --git a/home-mixer/scorers/ranking_scorer.rs b/home-mixer/scorers/ranking_scorer.rs index e411e1d0..b69bf32b 100644 --- a/home-mixer/scorers/ranking_scorer.rs +++ b/home-mixer/scorers/ranking_scorer.rs @@ -418,8 +418,20 @@ pub struct RankingScorer { } impl RankingScorer { + /// Non-finite Phoenix heads are missing predictions, not values. + /// `Some(NaN)` / `Some(±Inf)` must not enter the weighted sum or the + /// dwell-regret slate mean — one poisoned head otherwise fail-opens into + /// every candidate's rank. + fn finite_head(score: Option) -> f64 { + score.filter(|s| s.is_finite()).unwrap_or(0.0) + } + fn apply(score: Option, weight: f64) -> f64 { - score.unwrap_or(0.0) * weight + Self::finite_head(score) * weight + } + + fn persistable_score(score: f64) -> Option { + score.is_finite().then_some(score) } pub(crate) fn compute_weighted_score( @@ -554,13 +566,13 @@ impl RankingScorer { let mut mean_share_via_copy_link = 0.0; for c in candidates { let ps = &c.phoenix_scores; - mean_favorite += ps.favorite_score.unwrap_or(0.0); - mean_reply += ps.reply_score.unwrap_or(0.0); - mean_retweet += ps.retweet_score.unwrap_or(0.0); - mean_quote += ps.quote_score.unwrap_or(0.0); - mean_share += ps.share_score.unwrap_or(0.0); - mean_share_via_dm += ps.share_via_dm_score.unwrap_or(0.0); - mean_share_via_copy_link += ps.share_via_copy_link_score.unwrap_or(0.0); + mean_favorite += Self::finite_head(ps.favorite_score); + mean_reply += Self::finite_head(ps.reply_score); + mean_retweet += Self::finite_head(ps.retweet_score); + mean_quote += Self::finite_head(ps.quote_score); + mean_share += Self::finite_head(ps.share_score); + mean_share_via_dm += Self::finite_head(ps.share_via_dm_score); + mean_share_via_copy_link += Self::finite_head(ps.share_via_copy_link_score); } mean_favorite *= inv_n; mean_reply *= inv_n; @@ -589,14 +601,14 @@ impl RankingScorer { ps.share_via_copy_link_score, mean_share_via_copy_link, ); - let negative = w.neg_not_interested * ps.not_interested_score.unwrap_or(0.0) - + w.neg_block_author * ps.block_author_score.unwrap_or(0.0) - + w.neg_mute_author * ps.mute_author_score.unwrap_or(0.0) - + w.neg_report * ps.report_score.unwrap_or(0.0); + let negative = w.neg_not_interested * Self::finite_head(ps.not_interested_score) + + w.neg_block_author * Self::finite_head(ps.block_author_score) + + w.neg_mute_author * Self::finite_head(ps.mute_author_score) + + w.neg_report * Self::finite_head(ps.report_score); let modulation = 2.0 * Self::sigmoid(positive / temperature) * (negative.min(0.0) / temperature).exp(); - let dwell = ps.dwell_time.unwrap_or(0.0).max(w.dwell_floor).max(0.0); + let dwell = Self::finite_head(ps.dwell_time).max(w.dwell_floor).max(0.0); dwell * modulation }) .collect() @@ -606,7 +618,7 @@ impl RankingScorer { if mean < DWELL_REGRET_MEAN_EPS { 0.0 } else { - p.unwrap_or(0.0) / mean - 1.0 + Self::finite_head(p) / mean - 1.0 } } @@ -864,8 +876,8 @@ impl Scorer for RankingScorer { .enumerate() .map(|(i, (&weighted, score))| { Ok(PostCandidate { - weighted_score: Some(weighted), - score: Some(score), + weighted_score: Self::persistable_score(weighted), + score: Self::persistable_score(score), slate_context: persisted_contexts.as_ref().map(|contexts| contexts[i]), mpn_parts: Some(MpnParts { pos: weighted_parts[i].0, @@ -926,8 +938,8 @@ impl Scorer for RankingScorer { .enumerate() .map(|(i, (&weighted, score))| { Ok(PostCandidate { - weighted_score: Some(weighted), - score: Some(score), + weighted_score: Self::persistable_score(weighted), + score: Self::persistable_score(score), slate_context: persisted_contexts.as_ref().map(|contexts| contexts[i]), ..Default::default() }) @@ -1928,4 +1940,121 @@ mod tests { "weighted-mode score should be small: {weighted}" ); } + + #[test] + fn nan_favorite_head_does_not_poison_dwell_regret_slate() { + let poisoned = dr_candidate( + 1, + PhoenixScores { + favorite_score: Some(f64::NAN), + dwell_time: Some(10.0), + ..Default::default() + }, + ); + let clean = dr_candidate( + 2, + PhoenixScores { + favorite_score: Some(0.1), + dwell_time: Some(10.0), + ..Default::default() + }, + ); + let missing = dr_candidate( + 3, + PhoenixScores { + favorite_score: None, + dwell_time: Some(10.0), + ..Default::default() + }, + ); + let scores = RankingScorer::compute_dwell_regret_base_scores( + &dr_weights(), + &[poisoned, clean, missing], + ); + assert!( + scores.iter().all(|s| s.is_finite()), + "NaN head must not NaN the slate: {scores:?}" + ); + assert!( + (scores[0] - scores[2]).abs() < 1e-9, + "NaN head must match a missing head: nan={} missing={}", + scores[0], + scores[2] + ); + assert!( + scores[1] > scores[0], + "finite sibling must still rank: clean={} poisoned={}", + scores[1], + scores[0] + ); + } + + #[test] + fn inf_favorite_head_does_not_win_weighted_rank() { + let query = query_with_flags(&[ + ("rust_home_mixer_favorite_weight", "1.0"), + ("rust_home_mixer_cont_dwell_time_weight", "0.0"), + ]); + let weights = ScoringWeights::from_params(&query.params); + let inf = PostCandidate { + phoenix_scores: PhoenixScores { + favorite_score: Some(f64::INFINITY), + ..Default::default() + }, + ..candidate(1, Some(true)) + }; + let missing = candidate(1, Some(true)); + let liked = PostCandidate { + phoenix_scores: PhoenixScores { + favorite_score: Some(0.9), + ..Default::default() + }, + ..candidate(2, Some(true)) + }; + let inf_score = RankingScorer::compute_weighted_score(&weights, &query, &inf); + let missing_score = RankingScorer::compute_weighted_score(&weights, &query, &missing); + let liked_score = RankingScorer::compute_weighted_score(&weights, &query, &liked); + assert!(inf_score.is_finite(), "Inf head must not emit Inf: {inf_score}"); + assert!( + (inf_score - missing_score).abs() < 1e-9, + "Inf head must match a missing head: inf={inf_score} missing={missing_score}" + ); + assert!( + liked_score > inf_score, + "finite favorite must beat Inf-as-missing: liked={liked_score} inf={inf_score}" + ); + } + + #[tokio::test] + async fn non_finite_final_score_is_unset_for_topk() { + let scorer = test_scorer(); + let query = query_with_flags(&[ + ("rust_home_mixer_value_model_mode", "weighted"), + ("rust_home_mixer_enable_author_diversity", "false"), + ("rust_home_mixer_enable_author_size_ips", "false"), + ("rust_home_mixer_favorite_weight", "1.0"), + ]); + let poisoned = PostCandidate { + phoenix_scores: PhoenixScores { + favorite_score: Some(f64::NAN), + dwell_time: Some(f64::INFINITY), + ..Default::default() + }, + ..dr_candidate(1, PhoenixScores::default()) + }; + let scored = scorer.score(&query, std::slice::from_ref(&poisoned)).await; + let out = scored[0].as_ref().unwrap(); + if let Some(score) = out.score { + assert!( + score.is_finite(), + "persisted score must be finite or None: {score}" + ); + } + if let Some(weighted) = out.weighted_score { + assert!( + weighted.is_finite(), + "persisted weighted_score must be finite or None: {weighted}" + ); + } + } } diff --git a/home-mixer/selectors/top_k_score_selector.rs b/home-mixer/selectors/top_k_score_selector.rs index 6f0ba37b..d407c215 100644 --- a/home-mixer/selectors/top_k_score_selector.rs +++ b/home-mixer/selectors/top_k_score_selector.rs @@ -7,9 +7,52 @@ pub struct TopKScoreSelector; impl Selector for TopKScoreSelector { fn score(&self, candidate: &PostCandidate) -> f64 { - candidate.score.unwrap_or(f64::NEG_INFINITY) + candidate + .score + .filter(|s| s.is_finite()) + .unwrap_or(f64::NEG_INFINITY) } fn size(&self) -> Option { Some(params::TOP_K_CANDIDATES_TO_SELECT) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::candidate::PostCandidate; + use xai_candidate_pipeline::selector::Selector; + + #[test] + fn non_finite_scores_sort_last() { + let selector = TopKScoreSelector; + assert_eq!( + selector.score(&PostCandidate { + score: Some(1.5), + ..Default::default() + }), + 1.5 + ); + assert_eq!( + selector.score(&PostCandidate { + score: None, + ..Default::default() + }), + f64::NEG_INFINITY + ); + assert_eq!( + selector.score(&PostCandidate { + score: Some(f64::NAN), + ..Default::default() + }), + f64::NEG_INFINITY + ); + assert_eq!( + selector.score(&PostCandidate { + score: Some(f64::INFINITY), + ..Default::default() + }), + f64::NEG_INFINITY + ); + } +}