tidaldb/tidal/src/ranking/executor/formulas.rs
jx12n 9728194f16 fix: M0-M10 code-review pass2 remediation — all 91 findings
Resolves every finding in docs/reviews/M0-M10-code-review-2026-06-08-pass2.md
across the engine, network, server, and CLI crates: session restore,
replication/CRDT, WAL format and recovery, storage indexes, query/ranking
executors, cohort/community governance, and scatter-gather routing.

Adds regression tests:
- review_pass2_creator_search_filter
- review_pass2_d_replication
- review_pass2_query_for_session
- review_pass2_storage_indexes_bitmap_cache
- review_pass2_zone_a_sessions

Verified: cargo clippy -D warnings and full test suite green across all crates.
2026-06-09 12:21:00 -06:00

285 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Pure scoring formulas.
//!
//! Every function in this module is a stateless mathematical formula: it takes
//! numeric inputs and returns a score. No I/O, no struct methods, no ledger
//! access. This makes the formulas independently testable and trivially
//! verifiable against their source definitions.
/// Additive weight applied to the creator-interaction boost.
///
/// When a user has interacted with a creator, items from that creator receive
/// an additive score boost of `interaction_weight * INTERACTION_BOOST_WEIGHT`
/// before normalization. 0.3 is a tuning constant that keeps interaction
/// boosts meaningful without overwhelming the base signal score.
pub(super) const INTERACTION_BOOST_WEIGHT: f64 = 0.3;
/// Weight applied to a candidate's normalized retrieval (fused RRF) relevance
/// score when seeding the base of [`super::ProfileExecutor::compute_raw_score`].
///
/// On the SEARCH path the fused RRF score from Stage 1c is the *primary* ordering
/// signal (see `builtins::search`: "the fused RRF score from Stage 1c is the
/// primary ordering signal; the profile adds a small quality overlay on top");
/// the profile contributes only a light quality overlay. The default `search`
/// profile's overlay is `view*0.5 + like*0.8` over `DecayScore` reads. Crucially,
/// a `DecayScore` is a *running sum*, not a `[0, 1]` quantity, so the overlay for
/// a lightly-engaged item is already on the order of `~1.0``1.5`.
///
/// For the fused order to remain the anchor (rather than a co-equal term that
/// popularity can overturn), the relevance seed must out-scale that light overlay
/// by a wide margin. With relevance pre-normalized to `[0, 1]` by the caller, a
/// weight of `10.0` puts the relevance contribution in `[0, 10]` -- an order of
/// magnitude above the light overlay -- so the most-relevant document leads and
/// the quality overlay only reorders candidates of near-equal relevance (tie
/// breaking), exactly as the documented contract requires. It is deliberately
/// *not* unbounded: a genuinely strong quality overlay on a heavily-engaged item
/// can still nudge near-tied results, which is the intended overlay behavior.
pub(super) const RELEVANCE_BASE_WEIGHT: f64 = 10.0;
/// Additive weight applied to the preference-vector cosine boost.
///
/// When a candidate carries a content embedding, its cosine similarity to the
/// user's preference vector (in `[-1.0, 1.0]`) is multiplied by this weight and
/// added to the score before normalization. 0.3 mirrors
/// [`INTERACTION_BOOST_WEIGHT`] so embedding affinity and creator affinity
/// contribute on the same scale without overwhelming the base signal score.
pub(super) const PREFERENCE_BOOST_WEIGHT: f64 = 0.3;
/// Hot: `log10(max(upvotes - downvotes, 1)) / (age_hours + 2)^gravity`
pub(super) fn hot_score(views: f64, age_hours: f64, gravity: f64) -> f64 {
views.max(1.0).log10() / (age_hours + 2.0).powf(gravity)
}
/// Trending: weighted sum of view and share velocity.
pub(super) fn trending_score(view_velocity: f64, share_velocity: f64) -> f64 {
2.0f64.mul_add(share_velocity, view_velocity)
}
/// Controversial: `(pos * neg) / (pos + neg)^2`
pub(super) fn controversial_score(pos: f64, neg: f64) -> f64 {
let denom = (pos + neg).powi(2);
if denom < f64::EPSILON {
0.0
} else {
(pos * neg) / denom
}
}
/// Hidden gems: `quality / log10(view_count + 10)`
pub(super) fn hidden_gems_score(quality: f64, view_count: f64) -> f64 {
quality / (view_count + 10.0).log10()
}
/// Shuffle pseudo-random draw in `[0, 1)` from a per-(user, minute, item) seed.
///
/// Spec §11.6: `shuffle_score(item) = random(seed) * quality_weight(item)` with
/// `seed = hash(user_id, timestamp_minute)` — "same results for same user within
/// 1 minute". The item id is folded into the hash *after* the seed so the per-item
/// draw varies within a single seed (otherwise every item would share one random
/// value and `quality_weight` alone would order them, collapsing the random
/// sampling the mode exists to provide). Holding `(user_id, timestamp_minute)`
/// fixed makes the whole permutation reproducible across a page refresh, and
/// changing the user *or* crossing a minute boundary reshuffles — exactly the
/// "deterministic within short time windows" contract.
///
/// `user_id == 0` is the anonymous / no-user case (RETRIEVE without `FOR USER`):
/// the draw is then a stable per-minute global permutation rather than per-user
/// variety, which is the best we can do without a user identity.
pub(super) fn shuffle_random(user_id: u64, timestamp_minute: u64, entity_id: u64) -> f64 {
// Divide by 2^64 (named constant; `u64::MAX as f64` rounds to 2^64 anyway) so
// the result is in [0, 1) — a uniform pseudo-random draw seeded by the tuple.
const TWO_POW_64: f64 = 18_446_744_073_709_551_616.0;
let mut hasher = blake3::Hasher::new();
hasher.update(&user_id.to_le_bytes());
hasher.update(&timestamp_minute.to_le_bytes());
hasher.update(&entity_id.to_le_bytes());
let hash = hasher.finalize();
let bytes = hash.as_bytes();
let arr: [u8; 8] = [
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
];
let v = u64::from_le_bytes(arr);
#[allow(clippy::cast_precision_loss)]
{
v as f64 / TWO_POW_64
}
}
/// Shuffle quality score (spec §11.6):
/// `completion_rate * 0.5 + like_ratio * 0.3 + log10(views + 1) * 0.2`.
///
/// Inputs are read from the ledger by the caller: `completion_rate` is the
/// `completion` running decay score clamped to `[0, 1]`, `like_ratio` is
/// `likes / (views + 1)` clamped to `[0, 1]`, and `views` is the all-time view
/// count. A floor of `0.0` guards against a degenerate negative composite so the
/// `sqrt` in `shuffle_quality_weight` is always real.
pub(super) fn shuffle_quality_score(completion_rate: f64, like_ratio: f64, views: f64) -> f64 {
let log_views = (views + 1.0).log10();
completion_rate
.mul_add(0.5, like_ratio.mul_add(0.3, log_views * 0.2))
.max(0.0)
}
/// Shuffle quality weight: `sqrt(quality_score)` (spec §11.6). High-quality items
/// get a larger multiplier on their random draw, so they are *more likely* to
/// surface without being guaranteed. A zero-quality item keeps a weight of 0 and
/// therefore scores 0 (sorted last among shuffled items), matching the spec's
/// "high-quality items are more likely to appear but not guaranteed".
pub(super) fn shuffle_quality_weight(quality_score: f64) -> f64 {
quality_score.max(0.0).sqrt()
}
/// Haversine great-circle distance in kilometres.
///
/// Computes the shortest-path distance between two points on the Earth's
/// surface given their latitude and longitude in decimal degrees.
#[must_use]
pub fn haversine_km(lat1: f64, lng1: f64, lat2: f64, lng2: f64) -> f64 {
const R: f64 = 6371.0; // Earth's mean radius in km
let dlat = (lat2 - lat1).to_radians();
let dlng = (lng2 - lng1).to_radians();
let a = (lat1.to_radians().cos() * lat2.to_radians().cos())
.mul_add((dlng / 2.0).sin().powi(2), (dlat / 2.0).sin().powi(2));
2.0 * R * a.sqrt().asin()
}
// -- Tests --------------------------------------------------------------------
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::float_cmp)]
mod tests {
use super::*;
#[test]
fn score_hot_decays_older_candidates() {
// hot_score = log10(max(views, 1)) / (age_hours + 2)^gravity.
// Older content (more hours) scores lower than newer with equal views.
let gravity = 1.8_f64;
let score_new = hot_score(50.0, 2.0, gravity);
let score_old = hot_score(50.0, 48.0, gravity);
assert!(
score_new > score_old,
"newer content should score higher than older content with the same view count"
);
}
#[test]
fn score_trending_uses_velocity() {
// trending_score = view_velocity + 2.0 * share_velocity.
// Positive velocity inputs yield a positive score.
let score = trending_score(2.0, 1.0);
assert!(score > 0.0);
assert_eq!(trending_score(0.0, 0.0), 0.0);
}
#[test]
fn controversial_peaks_at_balanced_pos_neg() {
// controversial_score = (pos*neg) / (pos+neg)^2, which is maximized at
// pos == neg (value 0.25) and falls off as the split skews either way.
let balanced = controversial_score(50.0, 50.0);
let skewed = controversial_score(90.0, 10.0);
let one_sided = controversial_score(100.0, 0.0);
assert!(
(balanced - 0.25).abs() < 1e-9,
"balanced pos/neg should peak at 0.25, got {balanced}"
);
assert!(
balanced > skewed,
"balanced ({balanced}) should outscore skewed ({skewed})"
);
assert!(
skewed > one_sided,
"skewed ({skewed}) should outscore one-sided ({one_sided})"
);
}
#[test]
fn controversial_zero_when_no_signals() {
// Guarded denominator: no pos and no neg -> 0.0, not NaN.
let score = controversial_score(0.0, 0.0);
assert_eq!(score, 0.0);
assert!(score.is_finite());
}
#[test]
fn hidden_gems_rewards_quality_at_low_views() {
// hidden_gems_score = quality / log10(view_count + 10). For equal quality,
// a low-view item outscores a high-view one (the "undiscovered" bonus).
let low_views = hidden_gems_score(0.8, 0.0);
let high_views = hidden_gems_score(0.8, 1000.0);
assert!(
low_views > high_views,
"low-view gem ({low_views}) should outscore high-view item ({high_views})"
);
// At view_count=0, denominator is log10(10) = 1, so score == quality.
assert!(
(low_views - 0.8).abs() < 1e-9,
"score at 0 views should equal quality"
);
}
#[test]
fn shuffle_random_is_stable_for_fixed_seed_and_in_unit_range() {
// Spec §11.6: the draw is deterministic for a fixed (user, minute, item)
// tuple and lies in [0, 1).
let a = shuffle_random(42, 1000, 7);
let b = shuffle_random(42, 1000, 7);
assert_eq!(a, b, "same (user, minute, item) must give the same draw");
assert!((0.0..1.0).contains(&a), "draw must be in [0, 1), got {a}");
}
#[test]
fn shuffle_random_varies_by_user_minute_and_item() {
// Changing any seed component changes the draw (no fixed global hash).
let base = shuffle_random(1, 100, 9);
assert_ne!(base, shuffle_random(2, 100, 9), "user must change the draw");
assert_ne!(
base,
shuffle_random(1, 101, 9),
"minute must change the draw"
);
assert_ne!(base, shuffle_random(1, 100, 8), "item must change the draw");
}
#[test]
fn shuffle_quality_weight_rewards_quality() {
// quality_weight = sqrt(quality_score); higher quality -> larger weight.
let low = shuffle_quality_weight(shuffle_quality_score(0.1, 0.0, 1.0));
let high = shuffle_quality_weight(shuffle_quality_score(1.0, 1.0, 1000.0));
assert!(high > low, "higher quality must yield a larger weight");
// Zero quality -> zero weight -> a zero shuffle score (sorted last).
assert_eq!(
shuffle_quality_weight(shuffle_quality_score(0.0, 0.0, 0.0)),
0.0
);
// Weight is always real (the quality floor guards the sqrt).
assert!(shuffle_quality_weight(shuffle_quality_score(0.0, 0.0, 0.0)).is_finite());
}
#[test]
fn haversine_same_point_is_zero() {
let d = haversine_km(40.7128, -74.0060, 40.7128, -74.0060);
assert!(d.abs() < 1e-6, "same point should be 0 km, got {d}");
}
#[test]
fn haversine_nyc_to_london() {
// NYC (40.7128, -74.0060) to London (51.5074, -0.1278)
// Expected: ~5570 km
let d = haversine_km(40.7128, -74.0060, 51.5074, -0.1278);
assert!(
(5500.0..5650.0).contains(&d),
"NYC to London should be ~5570 km, got {d}"
);
}
#[test]
fn haversine_antipodes() {
// North pole (90, 0) to south pole (-90, 0) = half circumference ~20015 km
let d = haversine_km(90.0, 0.0, -90.0, 0.0);
assert!(
(20_000.0..20_040.0).contains(&d),
"pole to pole should be ~20015 km, got {d}"
);
}
}