//! Profile executor: scores and ranks candidates according to a `RankingProfile`. //! //! The executor implements a five-stage pipeline: //! 1. **Gate**: filter out candidates that fail any gate threshold //! 2. **Score**: compute raw scores per candidate using the profile's `Sort` mode //! 3. **Sort**: order candidates by descending score //! 4. **Normalize**: min-max normalize scores to `[0.0, 1.0]` //! 5. **Diversity**: apply per-creator and format-mix constraints via `DiversitySelector` pub mod context; pub mod formulas; pub mod helpers; // Re-export all public items so that `crate::ranking::executor::Foo` paths continue to work. pub use context::{ScoredCandidate, UserContext}; use std::collections::HashMap; use crate::load::DegradationLevel; use crate::schema::{EntityId, Timestamp}; use crate::session::SessionContext; use crate::signals::SignalLedger; use super::profile::RankingProfile; use crate::entities::{UserSignalIndex, UserStateIndex}; use formulas::INTERACTION_BOOST_WEIGHT; use helpers::{normalize, passes_gates, read_agg}; // -- Executor ----------------------------------------------------------------- /// Scores and ranks candidates according to a `RankingProfile`. /// /// Holds a reference to the `SignalLedger` for reading decay scores, windowed /// counts, and velocities. The executor itself is stateless -- all configuration /// comes from the profile passed to `score()`. pub struct ProfileExecutor<'a> { ledger: &'a SignalLedger, /// Optional reference to the `created_at` range index for age-aware hot scoring. /// When `None`, `score_hot` falls back to a 24-hour age assumption. /// /// TODO: Wire up a proper reverse lookup (`EntityId` -> `created_at_ns`) for O(1) /// age resolution. The `RangeIndex` maps `(value, entity_id)` for range queries, /// but does not support reverse lookup by entity ID. A separate `HashMap` /// would be needed. Deferred to M4. #[allow(dead_code)] pub created_at_index: Option<&'a crate::storage::indexes::range::RangeIndex>, /// M6: co-engagement index for `related` profile scoring. co_engagement: Option<&'a crate::entities::CoEngagementIndex>, /// M6: seed item for co-engagement scoring (used with `similar_to`). seed_item: Option, /// M6: per-user signal index for social-graph-scoped trending. user_signal_index: Option<&'a UserSignalIndex>, /// M6: resolved social subgraph user IDs for trending scope. social_graph_users: Option>, /// M6p3: item metadata for metadata-based sorts (alphabetical, duration). item_metadata: Option<&'a HashMap>>, /// M6p3: user state + user ID for `DateSaved` sort. user_state_for_date_saved: Option<(&'a UserStateIndex, u64)>, /// M7p2: degradation level for coarse-aggregate window substitution. degradation_level: DegradationLevel, } impl<'a> ProfileExecutor<'a> { #[must_use] pub const fn new(ledger: &'a SignalLedger) -> Self { Self { ledger, created_at_index: None, co_engagement: None, seed_item: None, user_signal_index: None, social_graph_users: None, item_metadata: None, user_state_for_date_saved: None, degradation_level: DegradationLevel::Full, } } /// Set the degradation level for coarse-aggregate window substitution. /// /// Under `CoarseAggregates`, `SignalAgg::Value` reads use `Window::AllTime` /// and `SignalAgg::Velocity` reads use `Window::TwentyFourHours`, reducing /// the number of fine-grained bucket scans during high-load scoring. #[must_use] pub(crate) const fn with_degradation_level(mut self, level: DegradationLevel) -> Self { self.degradation_level = level; self } /// Attach a `created_at` index for age-aware hot scoring. #[must_use] pub const fn with_created_at_index( mut self, index: &'a crate::storage::indexes::range::RangeIndex, ) -> Self { self.created_at_index = Some(index); self } /// Attach a co-engagement index for `related` profile scoring. #[must_use] pub const fn with_co_engagement( mut self, index: &'a crate::entities::CoEngagementIndex, ) -> Self { self.co_engagement = Some(index); self } /// Set the seed item for co-engagement scoring (from `similar_to`). #[must_use] pub const fn with_seed(mut self, seed: EntityId) -> Self { self.seed_item = Some(seed); self } /// Attach a per-user signal index for social-graph-scoped trending. #[must_use] pub const fn with_user_signal_index(mut self, index: &'a UserSignalIndex) -> Self { self.user_signal_index = Some(index); self } /// Set the resolved social subgraph user IDs for trending scope. /// /// When set together with `user_signal_index`, `score_trending` uses /// aggregate velocity across these users instead of the global ledger. #[must_use] pub fn with_social_graph_users(mut self, users: Vec) -> Self { self.social_graph_users = Some(users); self } /// Attach item metadata for metadata-based sorts (alphabetical, duration). #[must_use] pub const fn with_item_metadata( mut self, metadata: &'a HashMap>, ) -> Self { self.item_metadata = Some(metadata); self } /// Attach user state context for `DateSaved` sort. #[must_use] pub const fn with_date_saved_context( mut self, user_state: &'a UserStateIndex, user_id: u64, ) -> Self { self.user_state_for_date_saved = Some((user_state, user_id)); self } /// Score and rank a set of candidate entities according to the given profile. /// /// Executes the five-stage pipeline: gate -> score -> sort -> normalize -> diversity. /// /// - `candidates`: entity IDs to consider; gate-failing candidates are excluded. /// - `profile`: the ranking profile controlling sort mode, boosts, gates, and diversity. /// - `now`: the current timestamp, used for decay calculations. /// /// Returns candidates sorted by descending normalized score in `[0.0, 1.0]`. #[must_use] pub fn score( &self, candidates: &[EntityId], profile: &RankingProfile, now: Timestamp, ) -> Vec { self.score_inner(candidates, profile, now, None, &HashMap::new()) } /// Score and rank candidates with an optional FOR SESSION boost applied. /// /// When `session_ctx` is `Some`, applies an additive boost per candidate based /// on keyword hint matching: keywords from session annotations are matched against /// each item's metadata values. /// /// The session boost is applied after base scoring and before normalization. #[must_use] pub fn score_with_session( &self, candidates: &[EntityId], profile: &RankingProfile, now: Timestamp, session_ctx: Option<&SessionContext>, item_metadata: &HashMap>, ) -> Vec { self.score_inner(candidates, profile, now, session_ctx, item_metadata) } /// Score and rank candidates with user-specific personalization. /// /// In addition to the base scoring and optional session boost, this method /// applies an additive creator-interaction boost: items from creators the /// user has interacted with receive a positive boost proportional to the /// interaction weight times `INTERACTION_BOOST_WEIGHT`. /// /// The interaction boost is applied after base scoring and session boost, /// but before normalization, so it participates in the min-max range. #[must_use] pub fn score_personalized( &self, candidates: &[EntityId], profile: &RankingProfile, now: Timestamp, session_ctx: Option<&SessionContext>, user_ctx: &UserContext, item_metadata: &HashMap>, ) -> Vec { let empty: HashMap = HashMap::new(); let mut scored: Vec = candidates .iter() .filter(|&&entity_id| passes_gates(entity_id, &profile.gates, self.ledger)) .map(|&entity_id| { let (raw, snapshot) = self.compute_raw_score(entity_id, profile, now); let metadata = item_metadata.get(&entity_id.as_u64()).map_or(&empty, |m| m); let session_boost = session_ctx.map_or(0.0, |ctx| { Self::session_boost(entity_id.as_u64(), ctx, metadata) }); // Creator-interaction boost: look up the per-item boost from // the pre-computed map. Items from highly-interacted creators // get a positive additive boost before normalization. #[allow(clippy::cast_possible_truncation)] let interaction_boost = user_ctx .creator_interaction_boosts .get(&(entity_id.as_u64() as u32)) .map_or(0.0, |&weight| weight * INTERACTION_BOOST_WEIGHT); ScoredCandidate { entity_id, score: raw + session_boost + interaction_boost, signal_snapshot: snapshot, creator_id: None, format: None, } }) .collect(); // `sort_unstable_by` is safe here: ranking score ordering is not stability-sensitive. // `select_nth_unstable_by` optimization requires knowing `limit` at this site; // truncation is applied by the caller after normalization. scored.sort_unstable_by(|a, b| { b.score .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) }); // Normalize to [0, 1]. normalize(&mut scored); // Apply profile diversity constraints if any are set. if profile.diversity.max_per_creator.is_some() || profile.diversity.format_mix_max_fraction.is_some() { use super::diversity::{DiversityConstraints, DiversitySelector}; let constraints = DiversityConstraints { max_per_creator: profile.diversity.max_per_creator, format_mix_max_fraction: profile.diversity.format_mix_max_fraction, ..DiversityConstraints::new() }; let result = DiversitySelector::select(&scored, &constraints, scored.len()); scored = result.selected; } scored } /// Shared five-stage scoring pipeline: gate -> score -> sort -> normalize -> diversity. /// /// When `session_ctx` is `Some`, an additive session boost is applied to each /// candidate's raw score before normalization. `item_metadata` provides the /// metadata for keyword hint matching; pass an empty map when not needed. fn score_inner( &self, candidates: &[EntityId], profile: &RankingProfile, now: Timestamp, session_ctx: Option<&SessionContext>, item_metadata: &HashMap>, ) -> Vec { let empty: HashMap = HashMap::new(); let mut scored: Vec = candidates .iter() .filter(|&&entity_id| passes_gates(entity_id, &profile.gates, self.ledger)) .map(|&entity_id| { let (raw, snapshot) = self.compute_raw_score(entity_id, profile, now); let metadata = item_metadata.get(&entity_id.as_u64()).map_or(&empty, |m| m); let boost = session_ctx.map_or(0.0, |ctx| { Self::session_boost(entity_id.as_u64(), ctx, metadata) }); ScoredCandidate { entity_id, score: raw + boost, signal_snapshot: snapshot, creator_id: None, format: None, } }) .collect(); // `sort_unstable_by` is safe here: ranking score ordering is not stability-sensitive. // `select_nth_unstable_by` optimization requires knowing `limit` at this site; // truncation is applied by the caller after normalization. scored.sort_unstable_by(|a, b| { b.score .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) }); // Normalize to [0, 1]. normalize(&mut scored); // Apply profile diversity constraints if any are set. if profile.diversity.max_per_creator.is_some() || profile.diversity.format_mix_max_fraction.is_some() { use super::diversity::{DiversityConstraints, DiversitySelector}; let constraints = DiversityConstraints { max_per_creator: profile.diversity.max_per_creator, format_mix_max_fraction: profile.diversity.format_mix_max_fraction, ..DiversityConstraints::new() }; let result = DiversitySelector::select(&scored, &constraints, scored.len()); scored = result.selected; } scored } /// Compute the session-specific score boost for a single entity. /// /// - Keyword hint score: fraction of annotation keywords that match any metadata value. /// `hint_score = hits / total_keywords * 0.3` (capped via the [0,1] fraction). /// - Velocity boost: `vel / (vel + 1) * 0.2` (Michaelis-Menten saturation). fn session_boost( _entity_id: u64, ctx: &SessionContext, metadata: &HashMap, ) -> f64 { let total_kw: usize = ctx .keywords .iter() .flat_map(|h| h.split_whitespace()) .count(); let hits = ctx .keywords .iter() .flat_map(|h| h.split_whitespace()) .filter(|kw| { let kw_lower = kw.to_lowercase(); metadata .values() .any(|v| v.to_lowercase().contains(&kw_lower)) }) .count(); #[allow(clippy::cast_precision_loss)] let hint_score = if total_kw == 0 { 0.0 } else { hits as f64 / total_kw as f64 }; let vel_norm = ctx.reward_velocity / (ctx.reward_velocity + 1.0); hint_score * 0.3 + vel_norm * 0.2 } /// Compute raw score and signal snapshot for a single candidate. /// /// Returns `(score, snapshot)` where `snapshot` lists the raw signal values /// that contributed, for explain-ability in API responses. fn compute_raw_score( &self, entity_id: EntityId, profile: &RankingProfile, now: Timestamp, ) -> (f64, Vec<(String, f64)>) { let (base, mut snapshot) = self.score_by_sort(entity_id, profile.sort.as_ref(), now); // Apply boosts and capture their contributions in the snapshot. let boost_sum: f64 = profile .boosts .iter() .map(|b| { let val = read_agg( entity_id, &b.signal, &b.agg, b.window, self.ledger, self.degradation_level, ); let weighted = b.weight * val; if weighted != 0.0 { snapshot.push((format!("{}_boost", b.signal), weighted)); } weighted }) .sum(); // M6: co-engagement blend for `related`-style queries. // // The spec states: `final = embedding_sim × 0.6 + co_engagement × 0.3 + signal_score × 0.1` // (a normalized convex combination). The actual formula is additive rather than // a strict convex combination because embedding similarity is not available as // a per-candidate scoring signal in the executor: ANN retrieval (Stage 1) uses // the embedding for candidate *selection*, not for re-scoring. The 0.3 co-engagement // weight is correct; incorporating embedding_sim as a multiplicative factor requires // storing per-candidate ANN distances and plumbing them through the executor context // (deferred to a future milestone). // // Effective formula: base_signal_score + boost_sum + co_eng_score × 0.3 let co_eng_boost = if let (Some(co_eng), Some(seed)) = (self.co_engagement, self.seed_item) { let boost = f64::from(co_eng.score(seed, entity_id)) * 0.3; if boost != 0.0 { snapshot.push(("co_engagement".to_string(), boost)); } boost } else { 0.0 }; (base + boost_sum + co_eng_boost, snapshot) } } mod scoring; #[cfg(test)] #[allow(clippy::unwrap_used, clippy::float_cmp, clippy::cast_precision_loss)] mod tests;