diff --git a/tidal/examples/quickstart.rs b/tidal/examples/quickstart.rs index 01c83f8..a9869ed 100644 --- a/tidal/examples/quickstart.rs +++ b/tidal/examples/quickstart.rs @@ -127,23 +127,51 @@ fn main() -> Result<(), Box> { ); // ── 4. Record engagement signals ──────────────────────────────────── + // + // Different items receive different engagement counts. The `hot` profile + // ranks by cumulative view count with age decay — items with more views + // score higher, producing clearly differentiated results. + // + // Note: the `trending` profile ranks by *velocity* (events/second over a + // rolling window), which requires signals arriving over real elapsed time + // to populate hour-level buckets. For a self-contained demo, `hot` is the + // right choice. let now = Timestamp::now(); - let viewed_items = [1u64, 3, 7, 12, 18]; - let liked_items = [3u64, 7, 18]; - for &item_id in &viewed_items { - db.signal("view", EntityId::new(item_id), 1.0, now)?; - } - for &item_id in &liked_items { - db.signal("like", EntityId::new(item_id), 1.0, now)?; + // (item_id, signal_type, count) — different counts drive different scores. + let signals: &[(u64, &str, u32)] = &[ + (7, "view", 8), + (18, "view", 6), + (3, "view", 5), + (12, "view", 4), + (1, "view", 3), + (4, "view", 2), + (8, "view", 1), + (3, "like", 4), + (7, "like", 3), + (18, "like", 2), + (7, "share", 2), + (3, "share", 1), + ]; + + let mut view_count = 0u32; + let mut like_count = 0u32; + let mut share_count = 0u32; + + for &(item_id, signal_type, count) in signals { + for _ in 0..count { + db.signal(signal_type, EntityId::new(item_id), 1.0, now)?; + } + match signal_type { + "view" => view_count += count, + "like" => like_count += count, + "share" => share_count += count, + _ => {} + } } - println!( - "Recorded {} views and {} likes.", - viewed_items.len(), - liked_items.len() - ); + println!("Recorded {view_count} views, {like_count} likes, {share_count} shares."); // Verify signal state is live. let score = db.read_decay_score(EntityId::new(3), "view", 0)?; @@ -152,17 +180,19 @@ fn main() -> Result<(), Box> { // ── 5. Retrieve ranked results ────────────────────────────────────── - // The `trending` builtin profile ranks by share + view velocity with - // diversity enforcement (max 1 item per creator). + // The `hot` builtin profile ranks by cumulative view count with age decay + // (Reddit/HN-style). Items with more views score higher; the age factor + // penalises older content. All items are treated as 24 hours old here + // since metadata-based age lookup is wired in M3+. let query = tidaldb::query::retrieve::Retrieve::builder() - .profile("trending") + .profile("hot") .limit(10) .build()?; let results = db.retrieve(&query)?; println!( - "RETRIEVE profile=trending: {} results from {} candidates", + "RETRIEVE profile=hot: {} results from {} candidates", results.items.len(), results.total_candidates ); diff --git a/tidal/src/ranking/executor/mod.rs b/tidal/src/ranking/executor/mod.rs index 3f54999..fe5d779 100644 --- a/tidal/src/ranking/executor/mod.rs +++ b/tidal/src/ranking/executor/mod.rs @@ -214,7 +214,7 @@ impl<'a> ProfileExecutor<'a> { .iter() .filter(|&&entity_id| passes_gates(entity_id, &profile.gates, self.ledger)) .map(|&entity_id| { - let raw = self.compute_raw_score(entity_id, profile, now); + 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) @@ -232,7 +232,7 @@ impl<'a> ProfileExecutor<'a> { ScoredCandidate { entity_id, score: raw + session_boost + interaction_boost, - signal_snapshot: vec![], + signal_snapshot: snapshot, creator_id: None, format: None, } @@ -286,7 +286,7 @@ impl<'a> ProfileExecutor<'a> { .iter() .filter(|&&entity_id| passes_gates(entity_id, &profile.gates, self.ledger)) .map(|&entity_id| { - let raw = self.compute_raw_score(entity_id, profile, now); + 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) @@ -294,7 +294,7 @@ impl<'a> ProfileExecutor<'a> { ScoredCandidate { entity_id, score: raw + boost, - signal_snapshot: vec![], + signal_snapshot: snapshot, creator_id: None, format: None, } @@ -366,16 +366,19 @@ impl<'a> ProfileExecutor<'a> { hint_score * 0.3 + vel_norm * 0.2 } - /// Compute raw score for a single candidate based on the profile's sort mode. + /// 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 { - let base = self.score_by_sort(entity_id, profile.sort.as_ref(), now); + ) -> (f64, Vec<(String, f64)>) { + let (base, mut snapshot) = self.score_by_sort(entity_id, profile.sort.as_ref(), now); - // Apply boosts. + // Apply boosts and capture their contributions in the snapshot. let boost_sum: f64 = profile .boosts .iter() @@ -388,7 +391,11 @@ impl<'a> ProfileExecutor<'a> { self.ledger, self.degradation_level, ); - b.weight * val + let weighted = b.weight * val; + if weighted != 0.0 { + snapshot.push((format!("{}_boost", b.signal), weighted)); + } + weighted }) .sum(); @@ -406,12 +413,16 @@ impl<'a> ProfileExecutor<'a> { // 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) { - f64::from(co_eng.score(seed, entity_id)) * 0.3 + 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 + (base + boost_sum + co_eng_boost, snapshot) } } diff --git a/tidal/src/ranking/executor/scoring.rs b/tidal/src/ranking/executor/scoring.rs index f63db8d..5004824 100644 --- a/tidal/src/ranking/executor/scoring.rs +++ b/tidal/src/ranking/executor/scoring.rs @@ -13,19 +13,22 @@ use super::helpers::read_agg; use crate::ranking::profile::{SignalAgg, Sort}; impl ProfileExecutor<'_> { - /// Compute base score from the sort mode alone. + /// Compute base score and signal snapshot from the sort mode alone. + /// + /// Returns `(score, snapshot)` where `snapshot` lists the raw signal values + /// that contributed to the score, for explain-ability in API responses. pub(super) fn score_by_sort( &self, entity_id: EntityId, sort: Option<&Sort>, now: Timestamp, - ) -> f64 { + ) -> (f64, Vec<(String, f64)>) { match sort { Some(Sort::Hot { gravity }) => self.score_hot(entity_id, *gravity, now), Some(Sort::Trending) => self.score_trending(entity_id), Some(Sort::Controversial) => self.score_controversial(entity_id), Some(Sort::HiddenGems) => self.score_hidden_gems(entity_id), - Some(Sort::Shuffle) => shuffle_score(entity_id.as_u64()), + Some(Sort::Shuffle) => (shuffle_score(entity_id.as_u64()), vec![]), Some(Sort::New) => { // M2 limitation: entity metadata (`created_at`) is not accessible from the // executor. Entity ID is used as a proxy for recency -- ranks higher IDs @@ -37,33 +40,42 @@ impl ProfileExecutor<'_> { // precision loss for very large IDs, which is acceptable for ranking). #[allow(clippy::cast_precision_loss)] let score = entity_id.as_u64() as f64; - score + (score, vec![]) } Some(Sort::TopWindow { window }) => self.score_top_window(entity_id, *window), - Some(Sort::MostViewed { window }) => read_agg( - entity_id, - "view", - &SignalAgg::Value, - *window, - self.ledger, - self.degradation_level, - ), - Some(Sort::MostLiked { window }) => read_agg( - entity_id, - "like", - &SignalAgg::Value, - *window, - self.ledger, - self.degradation_level, - ), - Some(Sort::MostFollowed) => read_agg( - entity_id, - "follow", - &SignalAgg::Value, - Window::AllTime, - self.ledger, - self.degradation_level, - ), + Some(Sort::MostViewed { window }) => { + let val = read_agg( + entity_id, + "view", + &SignalAgg::Value, + *window, + self.ledger, + self.degradation_level, + ); + (val, vec![("view".to_string(), val)]) + } + Some(Sort::MostLiked { window }) => { + let val = read_agg( + entity_id, + "like", + &SignalAgg::Value, + *window, + self.ledger, + self.degradation_level, + ); + (val, vec![("like".to_string(), val)]) + } + Some(Sort::MostFollowed) => { + let val = read_agg( + entity_id, + "follow", + &SignalAgg::Value, + Window::AllTime, + self.ledger, + self.degradation_level, + ); + (val, vec![("follow".to_string(), val)]) + } Some(Sort::CreatorEngagementRate) => { let view_vel = read_agg( entity_id, @@ -81,43 +93,63 @@ impl ProfileExecutor<'_> { self.ledger, self.degradation_level, ); - view_vel + like_vel + ( + view_vel + like_vel, + vec![ + ("view_velocity".to_string(), view_vel), + ("like_velocity".to_string(), like_vel), + ], + ) } Some(Sort::Rising) => self.score_rising(entity_id), - Some(Sort::AlphabeticalAsc) => self.score_alphabetical_asc(entity_id), - Some(Sort::AlphabeticalDesc) => self.score_alphabetical_desc(entity_id), - Some(Sort::Shortest) => self.score_shortest(entity_id), - Some(Sort::Longest) => self.score_longest(entity_id), - Some(Sort::MostCommented { window }) => read_agg( - entity_id, - "comment", - &SignalAgg::Value, - *window, - self.ledger, - self.degradation_level, - ), - Some(Sort::MostShared { window }) => read_agg( - entity_id, - "share", - &SignalAgg::Value, - *window, - self.ledger, - self.degradation_level, - ), - Some(Sort::LiveViewerCount) => read_agg( - entity_id, - "viewer_count", - &SignalAgg::DecayScore, - Window::AllTime, - self.ledger, - self.degradation_level, - ), - Some(Sort::DateSaved) => self.score_date_saved(entity_id), - None => 0.0, + Some(Sort::AlphabeticalAsc) => (self.score_alphabetical_asc(entity_id), vec![]), + Some(Sort::AlphabeticalDesc) => (self.score_alphabetical_desc(entity_id), vec![]), + Some(Sort::Shortest) => (self.score_shortest(entity_id), vec![]), + Some(Sort::Longest) => (self.score_longest(entity_id), vec![]), + Some(Sort::MostCommented { window }) => { + let val = read_agg( + entity_id, + "comment", + &SignalAgg::Value, + *window, + self.ledger, + self.degradation_level, + ); + (val, vec![("comment".to_string(), val)]) + } + Some(Sort::MostShared { window }) => { + let val = read_agg( + entity_id, + "share", + &SignalAgg::Value, + *window, + self.ledger, + self.degradation_level, + ); + (val, vec![("share".to_string(), val)]) + } + Some(Sort::LiveViewerCount) => { + let val = read_agg( + entity_id, + "viewer_count", + &SignalAgg::DecayScore, + Window::AllTime, + self.ledger, + self.degradation_level, + ); + (val, vec![("viewer_count".to_string(), val)]) + } + Some(Sort::DateSaved) => (self.score_date_saved(entity_id), vec![]), + None => (0.0, vec![]), } } - fn score_hot(&self, entity_id: EntityId, gravity: f64, _now: Timestamp) -> f64 { + fn score_hot( + &self, + entity_id: EntityId, + gravity: f64, + _now: Timestamp, + ) -> (f64, Vec<(String, f64)>) { let views = read_agg( entity_id, "view", @@ -132,10 +164,10 @@ impl ProfileExecutor<'_> { // therefore ranks solely by view count at M2 scale. Per-entity age will be // wired in when `TidalDb::read_item()` is plumbed through the executor (M3+). let age_hours = 24.0_f64; - hot_score(views, age_hours, gravity) + (hot_score(views, age_hours, gravity), vec![("view".to_string(), views)]) } - fn score_trending(&self, entity_id: EntityId) -> f64 { + fn score_trending(&self, entity_id: EntityId) -> (f64, Vec<(String, f64)>) { // M6: social-graph-scoped trending. When a social subgraph and // per-user signal index are available, compute aggregate velocity // across the subgraph users instead of using the global ledger. @@ -152,7 +184,13 @@ impl ProfileExecutor<'_> { let share_vel = share_type_id.map_or(0.0, |tid| { user_signal_idx.aggregate_velocity(entity_id, users, tid, Window::TwentyFourHours) }); - return trending_score(view_vel, share_vel); + return ( + trending_score(view_vel, share_vel), + vec![ + ("view_velocity".to_string(), view_vel), + ("share_velocity".to_string(), share_vel), + ], + ); } // Fallback: global ledger velocity. @@ -172,10 +210,16 @@ impl ProfileExecutor<'_> { self.ledger, self.degradation_level, ); - trending_score(view_vel, share_vel) + ( + trending_score(view_vel, share_vel), + vec![ + ("view_velocity".to_string(), view_vel), + ("share_velocity".to_string(), share_vel), + ], + ) } - fn score_controversial(&self, entity_id: EntityId) -> f64 { + fn score_controversial(&self, entity_id: EntityId) -> (f64, Vec<(String, f64)>) { let pos = read_agg( entity_id, "like", @@ -192,10 +236,13 @@ impl ProfileExecutor<'_> { self.ledger, self.degradation_level, ); - controversial_score(pos, neg) + ( + controversial_score(pos, neg), + vec![("like".to_string(), pos), ("dislike".to_string(), neg)], + ) } - fn score_hidden_gems(&self, entity_id: EntityId) -> f64 { + fn score_hidden_gems(&self, entity_id: EntityId) -> (f64, Vec<(String, f64)>) { let quality = read_agg( entity_id, "completion", @@ -212,10 +259,16 @@ impl ProfileExecutor<'_> { self.ledger, self.degradation_level, ); - hidden_gems_score(quality, view_count) + ( + hidden_gems_score(quality, view_count), + vec![ + ("completion".to_string(), quality), + ("view".to_string(), view_count), + ], + ) } - fn score_top_window(&self, entity_id: EntityId, window: Window) -> f64 { + fn score_top_window(&self, entity_id: EntityId, window: Window) -> (f64, Vec<(String, f64)>) { let views = read_agg( entity_id, "view", @@ -248,13 +301,21 @@ impl ProfileExecutor<'_> { self.ledger, self.degradation_level, ); - views.mul_add( - 0.3, - likes.mul_add(0.3, shares.mul_add(0.2, completion * views * 0.1)), + ( + views.mul_add( + 0.3, + likes.mul_add(0.3, shares.mul_add(0.2, completion * views * 0.1)), + ), + vec![ + ("view".to_string(), views), + ("like".to_string(), likes), + ("share".to_string(), shares), + ("completion".to_string(), completion), + ], ) } - fn score_rising(&self, entity_id: EntityId) -> f64 { + fn score_rising(&self, entity_id: EntityId) -> (f64, Vec<(String, f64)>) { let short = read_agg( entity_id, "view", @@ -271,11 +332,14 @@ impl ProfileExecutor<'_> { self.ledger, self.degradation_level, ); - if long < f64::EPSILON { - short - } else { - short / long - } + let score = if long < f64::EPSILON { short } else { short / long }; + ( + score, + vec![ + ("view_velocity_1h".to_string(), short), + ("view_velocity_24h".to_string(), long), + ], + ) } /// Score for `AlphabeticalAsc`: pack first 8 bytes of lowercased title into a