- 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
202 lines
8.1 KiB
Rust
202 lines
8.1 KiB
Rust
//! 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}"
|
||
);
|
||
}
|
||
}
|