//! 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;