tidaldb/tidal/src/ranking/builtins.rs
2026-02-23 22:41:16 -07:00

525 lines
17 KiB
Rust

//! Built-in ranking profiles.
//!
//! `TidalDB` ships 25 default profiles covering the most common content ranking
//! patterns: trending, hot, new, top (by window), hidden gems, controversial,
//! most viewed, most liked, shuffle, four personalized profiles added in M3
//! (`for_you`, `following`, `related`, `notification`), a search profile (M5),
//! a cohort-scoped trending profile (M6, `cohort_trending`), and full sort mode
//! coverage profiles (M6p3: `live`, `alphabetical_asc`, `alphabetical_desc`,
//! `shortest`, `longest`, `most_commented`, `most_shared`, `date_saved`).
//!
//! Population-level profiles use `CandidateStrategy::Scan` with `sort_field =
//! "created_at"`. Personalized profiles use `Relationship` strategy.
//! All are registered at version 1 with `is_builtin = true`.
use crate::schema::Window;
use super::profile::{Boost, CandidateStrategy, DiversitySpec, RankingProfile, SignalAgg, Sort};
use super::registry::{ProfileError, ProfileRegistry};
/// Default candidate strategy for built-in profiles.
fn default_strategy() -> CandidateStrategy {
CandidateStrategy::Scan {
sort_field: "created_at".into(),
}
}
/// Build a profile skeleton with common defaults.
fn skeleton(name: &str) -> RankingProfile {
RankingProfile {
name: name.to_owned(),
version: 1,
candidate_strategy: default_strategy(),
boosts: vec![],
decay: None,
gates: vec![],
penalties: vec![],
excludes: vec![],
diversity: DiversitySpec::default(),
exploration: 0.0,
sort: None,
is_builtin: true,
}
}
// ── Profile tuning constants ──────────────────────────────────────────────
/// Age-decay gravity for the hot sort (Reddit-style HN algorithm).
/// Higher values decay older content faster.
const HOT_GRAVITY: f64 = 1.8;
/// Exploration fraction injected into shuffle results.
/// 0.5 = 50% random exploration, 50% signal-ranked.
const SHUFFLE_EXPLORATION: f64 = 0.5;
/// Signal weight multiplier for share events in the trending score.
const TRENDING_SHARE_WEIGHT: f64 = 2.0;
/// Maximum items per creator in trending results (content diversity).
const TRENDING_MAX_PER_CREATOR: usize = 1;
/// Maximum items per creator in hot results.
const HOT_MAX_PER_CREATOR: usize = 2;
/// Register all 25 built-in ranking profiles into the given registry.
///
/// # Errors
///
/// Returns `ProfileError` if any profile fails validation. This should never
/// happen for built-in profiles -- if it does, it is a bug in the profile
/// definitions.
pub fn register_builtins(registry: &mut ProfileRegistry) -> Result<(), ProfileError> {
// Population-level profiles (M2).
registry.register(trending())?;
registry.register(hot())?;
registry.register(new())?;
registry.register(top_week())?;
registry.register(top_month())?;
registry.register(top_all_time())?;
registry.register(hidden_gems())?;
registry.register(controversial())?;
registry.register(most_viewed())?;
registry.register(most_liked())?;
registry.register(shuffle())?;
// Personalized profiles (M3).
registry.register(for_you())?;
registry.register(following())?;
registry.register(related())?;
registry.register(notification())?;
// Search profile (M5).
registry.register(search())?;
// Cohort profile (M6).
registry.register(cohort_trending())?;
// M6p3 profiles: live content + full sort mode coverage.
registry.register(live())?;
registry.register(alphabetical_asc())?;
registry.register(alphabetical_desc())?;
registry.register(shortest())?;
registry.register(longest())?;
registry.register(most_commented())?;
registry.register(most_shared())?;
registry.register(date_saved())?;
Ok(())
}
fn trending() -> RankingProfile {
let mut p = skeleton("trending");
p.sort = Some(Sort::Trending);
p.boosts = vec![
Boost {
signal: "share".into(),
agg: SignalAgg::Velocity,
window: Window::TwentyFourHours,
weight: TRENDING_SHARE_WEIGHT,
},
Boost {
signal: "view".into(),
agg: SignalAgg::Velocity,
window: Window::TwentyFourHours,
weight: 1.0,
},
];
// Gate on engagement_ratio deferred to application-level profiles.
// Built-in profiles avoid gates on signals that may not be in the schema.
p.diversity = DiversitySpec {
max_per_creator: Some(TRENDING_MAX_PER_CREATOR),
..DiversitySpec::default()
};
p
}
fn hot() -> RankingProfile {
let mut p = skeleton("hot");
p.sort = Some(Sort::Hot {
gravity: HOT_GRAVITY,
});
p.boosts = vec![Boost {
signal: "view".into(),
agg: SignalAgg::Velocity,
window: Window::OneHour,
weight: 1.0,
}];
p.diversity = DiversitySpec {
max_per_creator: Some(HOT_MAX_PER_CREATOR),
..DiversitySpec::default()
};
p
}
fn new() -> RankingProfile {
let mut p = skeleton("new");
p.sort = Some(Sort::New);
p
}
fn top_week() -> RankingProfile {
let mut p = skeleton("top_week");
p.sort = Some(Sort::TopWindow {
window: Window::SevenDays,
});
p
}
fn top_month() -> RankingProfile {
let mut p = skeleton("top_month");
// TODO(m8): Use Window::ThirtyDays once hourly rollups are implemented.
// ThirtyDays currently returns 0 (not implemented), which produces arbitrary
// ordering. SevenDays is the best available approximation until M8 rollups.
p.sort = Some(Sort::TopWindow {
window: Window::SevenDays,
});
p
}
fn top_all_time() -> RankingProfile {
let mut p = skeleton("top_all_time");
p.sort = Some(Sort::TopWindow {
window: Window::AllTime,
});
p
}
fn hidden_gems() -> RankingProfile {
let mut p = skeleton("hidden_gems");
p.sort = Some(Sort::HiddenGems);
// Gates on completion/view deferred to application-level profiles.
// Built-in profiles avoid gates on signals that may not be in the schema.
p
}
fn controversial() -> RankingProfile {
let mut p = skeleton("controversial");
p.sort = Some(Sort::Controversial);
// Gates on like/dislike deferred to application-level profiles.
// Built-in profiles avoid gates on signals that may not be in the schema.
p
}
fn most_viewed() -> RankingProfile {
let mut p = skeleton("most_viewed");
p.sort = Some(Sort::MostViewed {
window: Window::SevenDays,
});
p
}
fn most_liked() -> RankingProfile {
let mut p = skeleton("most_liked");
p.sort = Some(Sort::MostLiked {
window: Window::SevenDays,
});
p
}
fn shuffle() -> RankingProfile {
let mut p = skeleton("shuffle");
p.sort = Some(Sort::Shuffle);
p.exploration = SHUFFLE_EXPLORATION;
p
}
// ── M3 Personalized Profiles ────────────────────────────────────────────────
/// Maximum items per creator in `for_you` results.
const FOR_YOU_MAX_PER_CREATOR: usize = 2;
/// Exploration fraction for the `for_you` profile.
/// 10% random exploration to prevent filter bubbles.
const FOR_YOU_EXPLORATION: f64 = 0.1;
/// Maximum items per creator in following results.
const FOLLOWING_MAX_PER_CREATOR: usize = 3;
/// `for_you`: personalized home feed ranking.
///
/// Combines interaction-weighted decay scores with exploration injection.
/// Uses `Scan` strategy (M3 user-context filtering in Stage 2.5 handles
/// unseen/unblocked). The `for_user` clause triggers preference-aware
/// scoring in the executor.
fn for_you() -> RankingProfile {
let mut p = skeleton("for_you");
p.sort = Some(Sort::Hot { gravity: 1.5 });
p.boosts = vec![
Boost {
signal: "view".into(),
agg: SignalAgg::DecayScore,
window: Window::AllTime,
weight: 1.0,
},
Boost {
signal: "like".into(),
agg: SignalAgg::DecayScore,
window: Window::AllTime,
weight: 2.0,
},
Boost {
signal: "share".into(),
agg: SignalAgg::Velocity,
window: Window::TwentyFourHours,
weight: 1.5,
},
];
p.diversity = DiversitySpec {
max_per_creator: Some(FOR_YOU_MAX_PER_CREATOR),
format_mix_max_fraction: Some(0.4),
};
p.exploration = FOR_YOU_EXPLORATION;
p
}
/// following: content from followed creators, ranked by interaction strength.
///
/// Uses `Relationship` candidate strategy -- the executor sources
/// candidates from the user's relationship graph. Ranked by recency
/// (`Sort::New`) with a view decay boost.
fn following() -> RankingProfile {
let mut p = skeleton("following");
p.candidate_strategy = CandidateStrategy::Relationship;
p.sort = Some(Sort::New);
p.boosts = vec![Boost {
signal: "view".into(),
agg: SignalAgg::Velocity,
window: Window::OneHour,
weight: 0.5,
}];
p.diversity = DiversitySpec {
max_per_creator: Some(FOLLOWING_MAX_PER_CREATOR),
..DiversitySpec::default()
};
p
}
/// related: "more like this" ranking for a seed item.
///
/// Uses `Scan` strategy. When `similar_to` is set in the query, the
/// executor will use the seed item's embedding for ANN in Stage 1
/// (when vector indexes are wired). For M3, falls back to scan with
/// content-type boosting.
fn related() -> RankingProfile {
let mut p = skeleton("related");
p.sort = Some(Sort::Hot { gravity: 1.2 });
p.boosts = vec![
Boost {
signal: "view".into(),
agg: SignalAgg::DecayScore,
window: Window::AllTime,
weight: 1.0,
},
Boost {
signal: "completion".into(),
agg: SignalAgg::DecayScore,
window: Window::AllTime,
weight: 1.5,
},
];
p.diversity = DiversitySpec {
max_per_creator: Some(2),
..DiversitySpec::default()
};
p
}
/// notification: items a user should be notified about.
///
/// Prioritizes high-velocity content from creators the user follows.
/// Strict diversity ensures no single creator dominates the notification
/// tray.
fn notification() -> RankingProfile {
let mut p = skeleton("notification");
p.candidate_strategy = CandidateStrategy::Relationship;
p.sort = Some(Sort::Trending);
p.boosts = vec![
Boost {
signal: "view".into(),
agg: SignalAgg::Velocity,
window: Window::OneHour,
weight: 2.0,
},
Boost {
signal: "like".into(),
agg: SignalAgg::Velocity,
window: Window::OneHour,
weight: 1.0,
},
];
p.diversity = DiversitySpec {
max_per_creator: Some(1),
..DiversitySpec::default()
};
p
}
// ── M5 Search Profile ───────────────────────────────────────────────────────
/// Weight for view decay score in the search profile.
///
/// Lower than personalized profiles to let text relevance dominate.
const SEARCH_VIEW_WEIGHT: f64 = 0.5;
/// Weight for like decay score in the search profile.
///
/// Captures quality signal: items frequently liked tend to be good results.
const SEARCH_LIKE_WEIGHT: f64 = 0.8;
/// `search`: text and vector relevance plus light signal re-ranking.
///
/// Default profile for the SEARCH query type. The heavy lifting is done by
/// RRF fusion (BM25 + ANN) in Stage 1c of the `SearchExecutor`. This profile
/// provides a lightweight signal overlay: a view-decay and like-decay boost to
/// surface quality content without overriding text relevance signals.
///
/// - No exploration injection (`exploration = 0.0`): search results must be
/// deterministic for a given query.
/// - No diversity enforcement: callers specify diversity explicitly via
/// `SearchBuilder::diversity()`.
/// - `sort = None`: the fused RRF score from Stage 1c is the primary ordering
/// signal; the profile adds a small quality overlay on top.
fn search() -> RankingProfile {
let mut p = skeleton("search");
p.boosts = vec![
Boost {
signal: "view".into(),
agg: SignalAgg::DecayScore,
window: Window::AllTime,
weight: SEARCH_VIEW_WEIGHT,
},
Boost {
signal: "like".into(),
agg: SignalAgg::DecayScore,
window: Window::AllTime,
weight: SEARCH_LIKE_WEIGHT,
},
];
// No diversity: callers control diversity via SearchBuilder.
// No exploration: search results are deterministic.
p.exploration = 0.0;
p.sort = None;
p
}
// ── M6 Cohort Profiles ───────────────────────────────────────────────────────
/// `cohort_trending`: trending content scoped to a named cohort.
///
/// Identical boosts and sort mode to the global `trending` profile, but
/// intended for use with `RetrieveBuilder::cohort("my_cohort")`. The executor
/// reads signal values from the cohort signal ledger instead of the global
/// ledger. Without a `cohort` clause, this profile behaves identically to
/// `trending`.
fn cohort_trending() -> RankingProfile {
let mut p = skeleton("cohort_trending");
p.sort = Some(Sort::Trending);
p.boosts = vec![
Boost {
signal: "share".into(),
agg: SignalAgg::Velocity,
window: Window::TwentyFourHours,
weight: TRENDING_SHARE_WEIGHT,
},
Boost {
signal: "view".into(),
agg: SignalAgg::Velocity,
window: Window::TwentyFourHours,
weight: 1.0,
},
];
p.diversity = DiversitySpec {
max_per_creator: Some(TRENDING_MAX_PER_CREATOR),
..DiversitySpec::default()
};
p
}
// ── M6p3 Live Content Profile ───────────────────────────────────────────────
/// Weight for the relationship-preference boost in the live profile.
///
/// Lower than the `following` profile's 0.5 because the primary sort key
/// (`LiveViewerCount`) already dominates ordering. The boost lifts content
/// from creators the user actively views (social circle), making relationship
/// weight "dominant" without overriding the viewer-count signal entirely.
const LIVE_RELATIONSHIP_BOOST_WEIGHT: f64 = 0.3;
/// `live`: live content ranking by current viewer count.
///
/// Sorts by `LiveViewerCount` (decayed `viewer_count` signal) with strict
/// per-creator diversity (max 1 per creator). A `view` velocity boost gives
/// preference to content from creators the user's social circle actively
/// watches (relationship-weight dominant, per UC-12).
fn live() -> RankingProfile {
let mut p = skeleton("live");
p.sort = Some(Sort::LiveViewerCount);
p.boosts = vec![Boost {
signal: "view".into(),
agg: SignalAgg::Velocity,
window: Window::OneHour,
weight: LIVE_RELATIONSHIP_BOOST_WEIGHT,
}];
p.diversity = DiversitySpec {
max_per_creator: Some(1),
..DiversitySpec::default()
};
p
}
/// `alphabetical_asc`: sort by item title A-Z (case-insensitive).
fn alphabetical_asc() -> RankingProfile {
let mut p = skeleton("alphabetical_asc");
p.sort = Some(Sort::AlphabeticalAsc);
p
}
/// `alphabetical_desc`: sort by item title Z-A (case-insensitive).
fn alphabetical_desc() -> RankingProfile {
let mut p = skeleton("alphabetical_desc");
p.sort = Some(Sort::AlphabeticalDesc);
p
}
/// `shortest`: sort by item duration, shortest first.
fn shortest() -> RankingProfile {
let mut p = skeleton("shortest");
p.sort = Some(Sort::Shortest);
p
}
/// `longest`: sort by item duration, longest first.
fn longest() -> RankingProfile {
let mut p = skeleton("longest");
p.sort = Some(Sort::Longest);
p
}
/// `most_commented`: sort by comment count (`AllTime` window).
fn most_commented() -> RankingProfile {
let mut p = skeleton("most_commented");
p.sort = Some(Sort::MostCommented {
window: Window::AllTime,
});
p
}
/// `most_shared`: sort by share count (`AllTime` window).
fn most_shared() -> RankingProfile {
let mut p = skeleton("most_shared");
p.sort = Some(Sort::MostShared {
window: Window::AllTime,
});
p
}
/// `date_saved`: sort by when the querying user saved the item (latest first).
///
/// Requires `FOR USER` context in the query. Without it, the executor
/// returns `QueryError::InvalidFilter`.
fn date_saved() -> RankingProfile {
let mut p = skeleton("date_saved");
p.sort = Some(Sort::DateSaved);
p
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::float_cmp)]
mod tests;