tidaldb/tidal/src/ranking/executor/formulas.rs
jx12n 3bcfb3c576 feat: Bazel build, crate docs/ai-lookup, docker images, and engine hardening
- Add BUILD.bazel across tidal, tidal-net, tidal-server, tidalctl for bzlmod build
- Add tidal/ crate docs (README, CHANGELOG, CONTRIBUTING, AGENTS, CLAUDE, API, ARCHITECTURE) and ai-lookup reference
- Add docker standalone/cluster/deploy images, compose, and prometheus config
- Harden WAL (batch format, writer, dedup, diagnostics), text syncer/collectors, and vector registry
- Expand tidalctl CLI and tests; restructure WAL/visibility integration test suites
- Refine tidal-net transport/client/server and tidal-server cluster/scatter-gather
2026-06-07 18:29:38 -06:00

202 lines
8.1 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: deterministic hash of entity ID for stable random ordering.
pub(super) fn shuffle_score(entity_id: u64) -> f64 {
let hash = blake3::hash(&entity_id.to_le_bytes());
let bytes = hash.as_bytes();
// First 8 bytes as u64, normalized to [0, 1].
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)]
let score = v as f64 / u64::MAX as f64;
score
}
/// 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 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}"
);
}
}