- 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
523 lines
17 KiB
Rust
523 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 super::{
|
|
profile::{Boost, CandidateStrategy, DiversitySpec, RankingProfile, SignalAgg, Sort},
|
|
registry::{ProfileError, ProfileRegistry},
|
|
};
|
|
use crate::schema::Window;
|
|
|
|
/// 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");
|
|
p.sort = Some(Sort::TopWindow {
|
|
window: Window::ThirtyDays,
|
|
});
|
|
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;
|