|
|
|
@ -117,10 +117,10 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
|
|
// --- apply_feedback: new = clamp(current + LEARNING_RATE(0.15) * surprise, -1, 1) ---
|
|
|
|
// --- apply_feedback: new = clamp(current + LEARNING_RATE(0.15) * surprise, -1, 1) ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Positive surprise (engaged more than predicted) should move the
|
|
|
|
|
|
|
|
/// score up, never down or unchanged.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn under_predicted_relevance_boosts_topic() {
|
|
|
|
fn under_predicted_relevance_boosts_topic() {
|
|
|
|
// Positive surprise (engaged more than predicted) should move the
|
|
|
|
|
|
|
|
// score up, never down or unchanged.
|
|
|
|
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let topics = vec!["rust".to_string()];
|
|
|
|
let topics = vec!["rust".to_string()];
|
|
|
|
// Model predicted 0.2 relevance, user fully read it: surprise = 0.8.
|
|
|
|
// Model predicted 0.2 relevance, user fully read it: surprise = 0.8.
|
|
|
|
@ -128,10 +128,10 @@ mod tests {
|
|
|
|
assert!(aff.score("rust") > 0.0);
|
|
|
|
assert!(aff.score("rust") > 0.0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Negative surprise (engaged less than predicted) should move the
|
|
|
|
|
|
|
|
/// score down, the mirror image of the boost case above.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn over_predicted_relevance_lowers_topic() {
|
|
|
|
fn over_predicted_relevance_lowers_topic() {
|
|
|
|
// Negative surprise (engaged less than predicted) should move the
|
|
|
|
|
|
|
|
// score down, the mirror image of the boost case above.
|
|
|
|
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let topics = vec!["crypto".to_string()];
|
|
|
|
let topics = vec!["crypto".to_string()];
|
|
|
|
// Model predicted 0.9, user dismissed unread: engagement 0, surprise = -0.9.
|
|
|
|
// Model predicted 0.9, user dismissed unread: engagement 0, surprise = -0.9.
|
|
|
|
@ -139,11 +139,11 @@ mod tests {
|
|
|
|
assert!(aff.score("crypto") < 0.0);
|
|
|
|
assert!(aff.score("crypto") < 0.0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Scores are documented to live in [-1.0, 1.0]. Repeated max-surprise
|
|
|
|
|
|
|
|
/// feedback would overshoot 1.0 without the clamp, so this guards the
|
|
|
|
|
|
|
|
/// invariant directly rather than trusting a single update.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn apply_feedback_clamps_at_positive_one() {
|
|
|
|
fn apply_feedback_clamps_at_positive_one() {
|
|
|
|
// Scores are documented to live in [-1.0, 1.0]. Repeated max-surprise
|
|
|
|
|
|
|
|
// feedback would overshoot 1.0 without the clamp, so this guards the
|
|
|
|
|
|
|
|
// invariant directly rather than trusting a single update.
|
|
|
|
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let topics = vec!["rust".to_string()];
|
|
|
|
let topics = vec!["rust".to_string()];
|
|
|
|
for _ in 0..20 {
|
|
|
|
for _ in 0..20 {
|
|
|
|
@ -152,9 +152,9 @@ mod tests {
|
|
|
|
assert_eq!(aff.score("rust"), 1.0);
|
|
|
|
assert_eq!(aff.score("rust"), 1.0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Verifies a decay from a large negative value doesn't overshoot and go beyond -1.0
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn apply_feedback_clamps_at_negative_one() {
|
|
|
|
fn apply_feedback_clamps_at_negative_one() {
|
|
|
|
// Same invariant as above, checked on the negative side.
|
|
|
|
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let topics = vec!["crypto".to_string()];
|
|
|
|
let topics = vec!["crypto".to_string()];
|
|
|
|
for _ in 0..20 {
|
|
|
|
for _ in 0..20 {
|
|
|
|
@ -163,11 +163,11 @@ mod tests {
|
|
|
|
assert_eq!(aff.score("crypto"), -1.0);
|
|
|
|
assert_eq!(aff.score("crypto"), -1.0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// apply_feedback loops over every topic on the article and applies
|
|
|
|
|
|
|
|
/// the same surprise to each independently; it must not skip topics
|
|
|
|
|
|
|
|
/// or bleed the update into topics the article wasn't tagged with.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn apply_feedback_updates_every_topic_on_the_article() {
|
|
|
|
fn apply_feedback_updates_every_topic_on_the_article() {
|
|
|
|
// apply_feedback loops over every topic on the article and applies
|
|
|
|
|
|
|
|
// the same surprise to each independently; it must not skip topics
|
|
|
|
|
|
|
|
// or bleed the update into topics the article wasn't tagged with.
|
|
|
|
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let topics = vec!["rust".to_string(), "async".to_string()];
|
|
|
|
let topics = vec!["rust".to_string(), "async".to_string()];
|
|
|
|
aff.apply_feedback(&topics, 0.4);
|
|
|
|
aff.apply_feedback(&topics, 0.4);
|
|
|
|
@ -177,10 +177,10 @@ mod tests {
|
|
|
|
assert_eq!(aff.score("crypto"), 0.0);
|
|
|
|
assert_eq!(aff.score("crypto"), 0.0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// surprise = 0.0 means engagement exactly matched the prediction, so
|
|
|
|
|
|
|
|
/// the score shouldn't move at all (current + 0.15 * 0.0 == current).
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn apply_feedback_zero_surprise_is_a_noop() {
|
|
|
|
fn apply_feedback_zero_surprise_is_a_noop() {
|
|
|
|
// surprise = 0.0 means engagement exactly matched the prediction, so
|
|
|
|
|
|
|
|
// the score shouldn't move at all (current + 0.15 * 0.0 == current).
|
|
|
|
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let topics = vec!["rust".to_string()];
|
|
|
|
let topics = vec!["rust".to_string()];
|
|
|
|
aff.apply_feedback(&topics, 0.5);
|
|
|
|
aff.apply_feedback(&topics, 0.5);
|
|
|
|
@ -191,10 +191,10 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
|
|
// --- decay: v -= sign(v) * DAILY_DECAY(0.02), settling at 0 instead of overshooting ---
|
|
|
|
// --- decay: v -= sign(v) * DAILY_DECAY(0.02), settling at 0 instead of overshooting ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Core decay behavior: a positive score should shrink toward zero
|
|
|
|
|
|
|
|
/// after one nightly pass, without crossing it.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn decay_pulls_toward_zero() {
|
|
|
|
fn decay_pulls_toward_zero() {
|
|
|
|
// Core decay behavior: a positive score should shrink toward zero
|
|
|
|
|
|
|
|
// after one nightly pass, without crossing it.
|
|
|
|
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
aff.apply_feedback(&["rust".to_string()], 1.0);
|
|
|
|
aff.apply_feedback(&["rust".to_string()], 1.0);
|
|
|
|
let before = aff.score("rust");
|
|
|
|
let before = aff.score("rust");
|
|
|
|
@ -203,11 +203,11 @@ mod tests {
|
|
|
|
assert!(aff.score("rust") > 0.0);
|
|
|
|
assert!(aff.score("rust") > 0.0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Pins the exact arithmetic (not just the direction) so a future
|
|
|
|
|
|
|
|
/// change to the decay formula is caught immediately.
|
|
|
|
|
|
|
|
/// surprise 1.0 -> 0.15, then one decay pass subtracts DAILY_DECAY (0.02).
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn decay_gives_expected_value() {
|
|
|
|
fn decay_gives_expected_value() {
|
|
|
|
// Pins the exact arithmetic (not just the direction) so a future
|
|
|
|
|
|
|
|
// change to the decay formula is caught immediately.
|
|
|
|
|
|
|
|
// surprise 1.0 -> 0.15, then one decay pass subtracts DAILY_DECAY (0.02).
|
|
|
|
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
aff.apply_feedback(&["java".to_string()], 1.0);
|
|
|
|
aff.apply_feedback(&["java".to_string()], 1.0);
|
|
|
|
aff.decay();
|
|
|
|
aff.decay();
|
|
|
|
@ -215,33 +215,33 @@ mod tests {
|
|
|
|
assert_eq!(aff.score("java"), 0.13);
|
|
|
|
assert_eq!(aff.score("java"), 0.13);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Regression test for a real bug: subtracting a fixed 0.02 from a
|
|
|
|
|
|
|
|
/// smaller score (e.g. 0.015) used to flip its sign to -0.005 instead
|
|
|
|
|
|
|
|
/// of landing on 0.0, which would make the score oscillate around
|
|
|
|
|
|
|
|
/// zero on every subsequent decay pass rather than settling.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn decay_settles_at_zero_instead_of_overshooting() {
|
|
|
|
fn decay_settles_at_zero_instead_of_overshooting() {
|
|
|
|
// Regression test for a real bug: subtracting a fixed 0.02 from a
|
|
|
|
|
|
|
|
// smaller score (e.g. 0.015) used to flip its sign to -0.005 instead
|
|
|
|
|
|
|
|
// of landing on 0.0, which would make the score oscillate around
|
|
|
|
|
|
|
|
// zero on every subsequent decay pass rather than settling.
|
|
|
|
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
aff.apply_feedback(&["rust".to_string()], 0.1); // score = 0.015
|
|
|
|
aff.apply_feedback(&["rust".to_string()], 0.1); // score = 0.015
|
|
|
|
aff.decay();
|
|
|
|
aff.decay();
|
|
|
|
assert_eq!(aff.score("rust"), 0.0);
|
|
|
|
assert_eq!(aff.score("rust"), 0.0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Same fix as above, verified on the negative side, and also checks
|
|
|
|
|
|
|
|
/// that the post-decay prune (dropping |v| <= 1e-4) actually removes
|
|
|
|
|
|
|
|
/// the entry rather than leaving a stray 0.0 in the map.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn decay_prunes_negative_scores_that_settle_at_zero() {
|
|
|
|
fn decay_prunes_negative_scores_that_settle_at_zero() {
|
|
|
|
// Same fix as above, verified on the negative side, and also checks
|
|
|
|
|
|
|
|
// that the post-decay prune (dropping |v| <= 1e-4) actually removes
|
|
|
|
|
|
|
|
// the entry rather than leaving a stray 0.0 in the map.
|
|
|
|
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
aff.apply_feedback(&["crypto".to_string()], -0.1); // score = -0.015
|
|
|
|
aff.apply_feedback(&["crypto".to_string()], -0.1); // score = -0.015
|
|
|
|
aff.decay();
|
|
|
|
aff.decay();
|
|
|
|
assert_eq!(aff.score("crypto"), 0.0);
|
|
|
|
assert_eq!(aff.score("crypto"), 0.0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// decay_pulls_toward_zero's mirror image: negative scores should
|
|
|
|
|
|
|
|
/// shrink in magnitude too, not just positive ones.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn decay_is_symmetric_for_negative_scores() {
|
|
|
|
fn decay_is_symmetric_for_negative_scores() {
|
|
|
|
// decay_pulls_toward_zero's mirror image: negative scores should
|
|
|
|
|
|
|
|
// shrink in magnitude too, not just positive ones.
|
|
|
|
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
aff.apply_feedback(&["crypto".to_string()], -1.0);
|
|
|
|
aff.apply_feedback(&["crypto".to_string()], -1.0);
|
|
|
|
let before = aff.score("crypto");
|
|
|
|
let before = aff.score("crypto");
|
|
|
|
@ -252,27 +252,27 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
|
|
// --- score / get_mean_affinity ---
|
|
|
|
// --- score / get_mean_affinity ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// A topic with no feedback yet must read as neutral (0.0), not
|
|
|
|
|
|
|
|
/// panic or return some other sentinel.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn score_defaults_to_zero_for_unknown_topic() {
|
|
|
|
fn score_defaults_to_zero_for_unknown_topic() {
|
|
|
|
// A topic with no feedback yet must read as neutral (0.0), not
|
|
|
|
|
|
|
|
// panic or return some other sentinel.
|
|
|
|
|
|
|
|
let aff = TopicAffinities::default();
|
|
|
|
let aff = TopicAffinities::default();
|
|
|
|
assert_eq!(aff.score("never-seen"), 0.0);
|
|
|
|
assert_eq!(aff.score("never-seen"), 0.0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Documented behavior for untagged articles: defer entirely to the
|
|
|
|
|
|
|
|
/// embedding/LLM stages by returning a neutral 0.0 rather than
|
|
|
|
|
|
|
|
/// dividing by zero.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn get_mean_affinity_given_empty_topics_returns_zero() {
|
|
|
|
fn get_mean_affinity_given_empty_topics_returns_zero() {
|
|
|
|
// Documented behavior for untagged articles: defer entirely to the
|
|
|
|
|
|
|
|
// embedding/LLM stages by returning a neutral 0.0 rather than
|
|
|
|
|
|
|
|
// dividing by zero.
|
|
|
|
|
|
|
|
let aff = TopicAffinities::default();
|
|
|
|
let aff = TopicAffinities::default();
|
|
|
|
assert_eq!(aff.get_mean_affinity(&[]), 0.0);
|
|
|
|
assert_eq!(aff.get_mean_affinity(&[]), 0.0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Confirms it's a plain arithmetic mean: an equally strong positive
|
|
|
|
|
|
|
|
/// and negative topic on the same article should cancel out to 0.0.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn get_mean_affinity_averages_across_topics() {
|
|
|
|
fn get_mean_affinity_averages_across_topics() {
|
|
|
|
// Confirms it's a plain arithmetic mean: an equally strong positive
|
|
|
|
|
|
|
|
// and negative topic on the same article should cancel out to 0.0.
|
|
|
|
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
aff.apply_feedback(&["rust".to_string()], 1.0); // 0.15
|
|
|
|
aff.apply_feedback(&["rust".to_string()], 1.0); // 0.15
|
|
|
|
aff.apply_feedback(&["crypto".to_string()], -1.0); // -0.15
|
|
|
|
aff.apply_feedback(&["crypto".to_string()], -1.0); // -0.15
|
|
|
|
@ -280,11 +280,11 @@ mod tests {
|
|
|
|
assert_eq!(aff.get_mean_affinity(&topics), 0.0);
|
|
|
|
assert_eq!(aff.get_mean_affinity(&topics), 0.0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// A topic mix of "known" and "never seen" shouldn't shrink the
|
|
|
|
|
|
|
|
/// denominator or get skipped — the unscored topic counts as 0.0 in
|
|
|
|
|
|
|
|
/// the average, per score()'s default.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn get_mean_affinity_treats_unscored_topics_as_zero() {
|
|
|
|
fn get_mean_affinity_treats_unscored_topics_as_zero() {
|
|
|
|
// A topic mix of "known" and "never seen" shouldn't shrink the
|
|
|
|
|
|
|
|
// denominator or get skipped — the unscored topic counts as 0.0 in
|
|
|
|
|
|
|
|
// the average, per score()'s default.
|
|
|
|
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
aff.apply_feedback(&["rust".to_string()], 1.0); // 0.15
|
|
|
|
aff.apply_feedback(&["rust".to_string()], 1.0); // 0.15
|
|
|
|
let topics = vec!["rust".to_string(), "never-seen".to_string()];
|
|
|
|
let topics = vec!["rust".to_string(), "never-seen".to_string()];
|
|
|
|
@ -293,10 +293,10 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
|
|
// --- top_n ---
|
|
|
|
// --- top_n ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// top_n is used to surface a user's strongest interests, so it must
|
|
|
|
|
|
|
|
/// sort highest-first (not insertion order) and respect the limit.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn top_n_sorts_descending_and_truncates() {
|
|
|
|
fn top_n_sorts_descending_and_truncates() {
|
|
|
|
// top_n is used to surface a user's strongest interests, so it must
|
|
|
|
|
|
|
|
// sort highest-first (not insertion order) and respect the limit.
|
|
|
|
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
let mut aff = TopicAffinities::default();
|
|
|
|
aff.apply_feedback(&["low".to_string()], 0.2);
|
|
|
|
aff.apply_feedback(&["low".to_string()], 0.2);
|
|
|
|
aff.apply_feedback(&["high".to_string()], 1.0);
|
|
|
|
aff.apply_feedback(&["high".to_string()], 1.0);
|
|
|
|
@ -310,72 +310,72 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
|
|
// --- engagement_score ---
|
|
|
|
// --- engagement_score ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// No signal at all (not opened, not dismissed) is neutral, not
|
|
|
|
|
|
|
|
/// penalized.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn engagement_score_never_opened_is_zero() {
|
|
|
|
fn engagement_score_never_opened_is_zero() {
|
|
|
|
// No signal at all (not opened, not dismissed) is neutral, not
|
|
|
|
|
|
|
|
// penalized.
|
|
|
|
|
|
|
|
assert_eq!(engagement_score(false, None, None, false, false), 0.0);
|
|
|
|
assert_eq!(engagement_score(false, None, None, false, false), 0.0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Dismissing without opening is an explicit negative signal, but
|
|
|
|
|
|
|
|
/// engagement_score itself is floored at 0.0 (the doc comment notes
|
|
|
|
|
|
|
|
/// the negative direction is expressed later via `surprise`, not
|
|
|
|
|
|
|
|
/// here) — this pins that the dismissed+!opened branch returns 0.0,
|
|
|
|
|
|
|
|
/// not a negative number.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn engagement_score_dismissed_without_opening_is_zero() {
|
|
|
|
fn engagement_score_dismissed_without_opening_is_zero() {
|
|
|
|
// Dismissing without opening is an explicit negative signal, but
|
|
|
|
|
|
|
|
// engagement_score itself is floored at 0.0 (the doc comment notes
|
|
|
|
|
|
|
|
// the negative direction is expressed later via `surprise`, not
|
|
|
|
|
|
|
|
// here) — this pins that the dismissed+!opened branch returns 0.0,
|
|
|
|
|
|
|
|
// not a negative number.
|
|
|
|
|
|
|
|
assert_eq!(engagement_score(false, None, None, false, true), 0.0);
|
|
|
|
assert_eq!(engagement_score(false, None, None, false, true), 0.0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Reading half the estimated time should score as half-engaged.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn engagement_score_opened_uses_dwell_over_estimate_ratio() {
|
|
|
|
fn engagement_score_opened_uses_dwell_over_estimate_ratio() {
|
|
|
|
// Reading half the estimated time should score as half-engaged.
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
assert_eq!(
|
|
|
|
engagement_score(true, Some(30), Some(60), false, false),
|
|
|
|
engagement_score(true, Some(30), Some(60), false, false),
|
|
|
|
0.5
|
|
|
|
0.5
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Dwelling far longer than the estimate (e.g. left the tab open)
|
|
|
|
|
|
|
|
/// must not push the score above the documented [0.0, 1.0] range.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn engagement_score_opened_caps_ratio_at_one() {
|
|
|
|
fn engagement_score_opened_caps_ratio_at_one() {
|
|
|
|
// Dwelling far longer than the estimate (e.g. left the tab open)
|
|
|
|
|
|
|
|
// must not push the score above the documented [0.0, 1.0] range.
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
assert_eq!(
|
|
|
|
engagement_score(true, Some(600), Some(60), false, false),
|
|
|
|
engagement_score(true, Some(600), Some(60), false, false),
|
|
|
|
1.0
|
|
|
|
1.0
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// When we simply don't have dwell/estimate data yet, the code
|
|
|
|
|
|
|
|
/// credits partial engagement (0.5) rather than assuming 0 (unfairly
|
|
|
|
|
|
|
|
/// penalizing) or 1 (unfairly rewarding).
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn engagement_score_opened_without_dwell_or_estimate_defaults_to_half() {
|
|
|
|
fn engagement_score_opened_without_dwell_or_estimate_defaults_to_half() {
|
|
|
|
// When we simply don't have dwell/estimate data yet, the code
|
|
|
|
|
|
|
|
// credits partial engagement (0.5) rather than assuming 0 (unfairly
|
|
|
|
|
|
|
|
// penalizing) or 1 (unfairly rewarding).
|
|
|
|
|
|
|
|
assert_eq!(engagement_score(true, None, None, false, false), 0.5);
|
|
|
|
assert_eq!(engagement_score(true, None, None, false, false), 0.5);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// est == 0 would divide by zero, so the `est > 0` guard routes this
|
|
|
|
|
|
|
|
/// case to the same "unknown read time" default (0.5) instead of
|
|
|
|
|
|
|
|
/// panicking or producing NaN/infinity.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn engagement_score_opened_with_zero_estimate_defaults_to_half() {
|
|
|
|
fn engagement_score_opened_with_zero_estimate_defaults_to_half() {
|
|
|
|
// est == 0 would divide by zero, so the `est > 0` guard routes this
|
|
|
|
|
|
|
|
// case to the same "unknown read time" default (0.5) instead of
|
|
|
|
|
|
|
|
// panicking or producing NaN/infinity.
|
|
|
|
|
|
|
|
assert_eq!(engagement_score(true, Some(10), Some(0), false, false), 0.5);
|
|
|
|
assert_eq!(engagement_score(true, Some(10), Some(0), false, false), 0.5);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Starring is an explicit "yes" beyond dwell time: it should add
|
|
|
|
|
|
|
|
/// 0.3 on top of the dwell-ratio score.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn engagement_score_starred_adds_bonus() {
|
|
|
|
fn engagement_score_starred_adds_bonus() {
|
|
|
|
// Starring is an explicit "yes" beyond dwell time: it should add
|
|
|
|
|
|
|
|
// 0.3 on top of the dwell-ratio score.
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
assert_eq!(
|
|
|
|
engagement_score(true, Some(30), Some(60), true, false),
|
|
|
|
engagement_score(true, Some(30), Some(60), true, false),
|
|
|
|
0.8
|
|
|
|
0.8
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// The +0.3 star bonus must also respect the 1.0 ceiling, even when
|
|
|
|
|
|
|
|
/// the dwell ratio alone is already at the max.
|
|
|
|
#[test]
|
|
|
|
#[test]
|
|
|
|
fn engagement_score_starred_bonus_caps_at_one() {
|
|
|
|
fn engagement_score_starred_bonus_caps_at_one() {
|
|
|
|
// The +0.3 star bonus must also respect the 1.0 ceiling, even when
|
|
|
|
|
|
|
|
// the dwell ratio alone is already at the max.
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
assert_eq!(
|
|
|
|
engagement_score(true, Some(60), Some(60), true, false),
|
|
|
|
engagement_score(true, Some(60), Some(60), true, false),
|
|
|
|
1.0
|
|
|
|
1.0
|
|
|
|
|