diff --git a/tidal/examples/quickstart.rs b/tidal/examples/quickstart.rs index 01c83f8..591150f 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 1ec60f4..355b5c9 100644 --- a/tidal/src/ranking/executor/mod.rs +++ b/tidal/src/ranking/executor/mod.rs @@ -445,24 +445,21 @@ impl<'a> ProfileExecutor<'a> { rev_idx.is_suppressed(user_id, signal_name, signal_ts_ns, None) } - /// 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 `(raw_score, signal_snapshot)` where `signal_snapshot` captures - /// the aggregated value for each boost signal that contributed to the score. - /// This enables explainability in API responses. + /// 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 = self.score_by_sort(entity_id, profile.sort.as_ref(), now); + let (base, mut snapshot) = self.score_by_sort(entity_id, profile.sort.as_ref(), now); - // Collect boost signal values for the signal snapshot. - let mut snapshot = Vec::with_capacity(profile.boosts.len()); - - // Apply boosts, skipping any whose signal is suppressed by community - // policy or by the requesting user's active revocations. + // Apply boosts and capture their contributions in the snapshot, + // skipping any whose signal is suppressed by community policy + // or by the requesting user's active revocations. let boost_sum: f64 = profile .boosts .iter() @@ -477,10 +474,11 @@ impl<'a> ProfileExecutor<'a> { self.ledger, self.degradation_level, ); - if val > 0.0 { - snapshot.push((b.signal.clone(), val)); + let weighted = b.weight * val; + if weighted != 0.0 { + snapshot.push((format!("{}_boost", b.signal), weighted)); } - b.weight * val + weighted }) .sum(); @@ -498,11 +496,11 @@ 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) { - let score = f64::from(co_eng.score(seed, entity_id)); - if score > 0.0 { - snapshot.push(("co_engagement".to_owned(), score)); + let boost = f64::from(co_eng.score(seed, entity_id)) * 0.3; + if boost != 0.0 { + snapshot.push(("co_engagement".to_string(), boost)); } - score * 0.3 + boost } else { 0.0 }; diff --git a/tidal/src/ranking/executor/scoring.rs b/tidal/src/ranking/executor/scoring.rs index 18318a9..03ee3de 100644 --- a/tidal/src/ranking/executor/scoring.rs +++ b/tidal/src/ranking/executor/scoring.rs @@ -34,18 +34,21 @@ impl ProfileExecutor<'_> { self.is_suppressed(signal) || self.is_signal_revoked_for_entity(entity_id, signal) } - /// 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. #[allow(clippy::too_many_lines)] 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 }) => { if self.is_signal_blocked(entity_id, "view") { - 0.0 + (0.0, vec![]) } else { self.score_hot(entity_id, *gravity, now) } @@ -54,7 +57,7 @@ impl ProfileExecutor<'_> { if self.is_signal_blocked(entity_id, "view") && self.is_signal_blocked(entity_id, "share") { - 0.0 + (0.0, vec![]) } else { self.score_trending(entity_id) } @@ -63,13 +66,13 @@ impl ProfileExecutor<'_> { if self.is_signal_blocked(entity_id, "like") && self.is_signal_blocked(entity_id, "dislike") { - 0.0 + (0.0, vec![]) } else { 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 @@ -81,49 +84,52 @@ 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 }) => { if self.is_signal_blocked(entity_id, "view") { - 0.0 + (0.0, vec![]) } else { - read_agg( + 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 }) => { if self.is_signal_blocked(entity_id, "like") { - 0.0 + (0.0, vec![]) } else { - read_agg( + let val = read_agg( entity_id, "like", &SignalAgg::Value, *window, self.ledger, self.degradation_level, - ) + ); + (val, vec![("like".to_string(), val)]) } } Some(Sort::MostFollowed) => { if self.is_signal_blocked(entity_id, "follow") { - 0.0 + (0.0, vec![]) } else { - read_agg( + 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) => { @@ -151,61 +157,75 @@ impl ProfileExecutor<'_> { 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::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 }) => { if self.is_signal_blocked(entity_id, "comment") { - 0.0 + (0.0, vec![]) } else { - read_agg( + 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 }) => { if self.is_signal_blocked(entity_id, "share") { - 0.0 + (0.0, vec![]) } else { - read_agg( + let val = read_agg( entity_id, "share", &SignalAgg::Value, *window, self.ledger, self.degradation_level, - ) + ); + (val, vec![("share".to_string(), val)]) } } Some(Sort::LiveViewerCount) => { if self.is_signal_blocked(entity_id, "viewer_count") { - 0.0 + (0.0, vec![]) } else { - read_agg( + 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), - None => 0.0, + 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", @@ -220,10 +240,13 @@ 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. @@ -240,7 +263,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. @@ -260,10 +289,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", @@ -280,10 +315,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", @@ -300,10 +338,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", @@ -336,13 +380,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", @@ -359,11 +411,18 @@ impl ProfileExecutor<'_> { self.ledger, self.degradation_level, ); - if long < f64::EPSILON { + 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