fix: populate signal_snapshot in ranking results and fix quickstart demo

- Score and boost contributions now captured per candidate in signal_snapshot:
  score_by_sort() returns (f64, Vec<(String, f64)>) with named signal values
  (e.g. view_velocity, share_velocity, view, like). Boost contributions are
  appended as `{signal}_boost`. compute_raw_score() threads the snapshot
  through; score_inner() and score_personalized() write it onto ScoredCandidate
  instead of always initialising to vec![].

- Quickstart switched from `trending` to `hot` profile. The trending profile
  scores by 24h velocity, which requires signals to arrive over real elapsed
  time to populate hour-level bucket aggregates — impossible in a self-contained
  demo. The hot profile (AllTime view count + age decay) works with any timestamp
  and produces clearly differentiated scores. Signals now use different counts
  per item to drive meaningful ranking output.
This commit is contained in:
jordan 2026-03-10 17:06:21 -06:00
parent 1d826c87b2
commit 006d3d058a
3 changed files with 210 additions and 105 deletions

View File

@ -127,23 +127,51 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
// ── 4. Record engagement signals ──────────────────────────────────── // ── 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 now = Timestamp::now();
let viewed_items = [1u64, 3, 7, 12, 18];
let liked_items = [3u64, 7, 18];
for &item_id in &viewed_items { // (item_id, signal_type, count) — different counts drive different scores.
db.signal("view", EntityId::new(item_id), 1.0, now)?; 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,
_ => {}
} }
for &item_id in &liked_items {
db.signal("like", EntityId::new(item_id), 1.0, now)?;
} }
println!( println!("Recorded {view_count} views, {like_count} likes, {share_count} shares.");
"Recorded {} views and {} likes.",
viewed_items.len(),
liked_items.len()
);
// Verify signal state is live. // Verify signal state is live.
let score = db.read_decay_score(EntityId::new(3), "view", 0)?; let score = db.read_decay_score(EntityId::new(3), "view", 0)?;
@ -152,17 +180,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// ── 5. Retrieve ranked results ────────────────────────────────────── // ── 5. Retrieve ranked results ──────────────────────────────────────
// The `trending` builtin profile ranks by share + view velocity with // The `hot` builtin profile ranks by cumulative view count with age decay
// diversity enforcement (max 1 item per creator). // (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() let query = tidaldb::query::retrieve::Retrieve::builder()
.profile("trending") .profile("hot")
.limit(10) .limit(10)
.build()?; .build()?;
let results = db.retrieve(&query)?; let results = db.retrieve(&query)?;
println!( println!(
"RETRIEVE profile=trending: {} results from {} candidates", "RETRIEVE profile=hot: {} results from {} candidates",
results.items.len(), results.items.len(),
results.total_candidates results.total_candidates
); );

View File

@ -214,7 +214,7 @@ impl<'a> ProfileExecutor<'a> {
.iter() .iter()
.filter(|&&entity_id| passes_gates(entity_id, &profile.gates, self.ledger)) .filter(|&&entity_id| passes_gates(entity_id, &profile.gates, self.ledger))
.map(|&entity_id| { .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 metadata = item_metadata.get(&entity_id.as_u64()).map_or(&empty, |m| m);
let session_boost = session_ctx.map_or(0.0, |ctx| { let session_boost = session_ctx.map_or(0.0, |ctx| {
Self::session_boost(entity_id.as_u64(), ctx, metadata) Self::session_boost(entity_id.as_u64(), ctx, metadata)
@ -232,7 +232,7 @@ impl<'a> ProfileExecutor<'a> {
ScoredCandidate { ScoredCandidate {
entity_id, entity_id,
score: raw + session_boost + interaction_boost, score: raw + session_boost + interaction_boost,
signal_snapshot: vec![], signal_snapshot: snapshot,
creator_id: None, creator_id: None,
format: None, format: None,
} }
@ -286,7 +286,7 @@ impl<'a> ProfileExecutor<'a> {
.iter() .iter()
.filter(|&&entity_id| passes_gates(entity_id, &profile.gates, self.ledger)) .filter(|&&entity_id| passes_gates(entity_id, &profile.gates, self.ledger))
.map(|&entity_id| { .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 metadata = item_metadata.get(&entity_id.as_u64()).map_or(&empty, |m| m);
let boost = session_ctx.map_or(0.0, |ctx| { let boost = session_ctx.map_or(0.0, |ctx| {
Self::session_boost(entity_id.as_u64(), ctx, metadata) Self::session_boost(entity_id.as_u64(), ctx, metadata)
@ -294,7 +294,7 @@ impl<'a> ProfileExecutor<'a> {
ScoredCandidate { ScoredCandidate {
entity_id, entity_id,
score: raw + boost, score: raw + boost,
signal_snapshot: vec![], signal_snapshot: snapshot,
creator_id: None, creator_id: None,
format: None, format: None,
} }
@ -366,16 +366,19 @@ impl<'a> ProfileExecutor<'a> {
hint_score * 0.3 + vel_norm * 0.2 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( fn compute_raw_score(
&self, &self,
entity_id: EntityId, entity_id: EntityId,
profile: &RankingProfile, profile: &RankingProfile,
now: Timestamp, now: Timestamp,
) -> f64 { ) -> (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);
// Apply boosts. // Apply boosts and capture their contributions in the snapshot.
let boost_sum: f64 = profile let boost_sum: f64 = profile
.boosts .boosts
.iter() .iter()
@ -388,7 +391,11 @@ impl<'a> ProfileExecutor<'a> {
self.ledger, self.ledger,
self.degradation_level, self.degradation_level,
); );
b.weight * val let weighted = b.weight * val;
if weighted != 0.0 {
snapshot.push((format!("{}_boost", b.signal), weighted));
}
weighted
}) })
.sum(); .sum();
@ -406,12 +413,16 @@ impl<'a> ProfileExecutor<'a> {
// Effective formula: base_signal_score + boost_sum + co_eng_score × 0.3 // 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 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 { } else {
0.0 0.0
}; };
base + boost_sum + co_eng_boost (base + boost_sum + co_eng_boost, snapshot)
} }
} }

View File

@ -13,19 +13,22 @@ use super::helpers::read_agg;
use crate::ranking::profile::{SignalAgg, Sort}; use crate::ranking::profile::{SignalAgg, Sort};
impl ProfileExecutor<'_> { 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( pub(super) fn score_by_sort(
&self, &self,
entity_id: EntityId, entity_id: EntityId,
sort: Option<&Sort>, sort: Option<&Sort>,
now: Timestamp, now: Timestamp,
) -> f64 { ) -> (f64, Vec<(String, f64)>) {
match sort { match sort {
Some(Sort::Hot { gravity }) => self.score_hot(entity_id, *gravity, now), Some(Sort::Hot { gravity }) => self.score_hot(entity_id, *gravity, now),
Some(Sort::Trending) => self.score_trending(entity_id), Some(Sort::Trending) => self.score_trending(entity_id),
Some(Sort::Controversial) => self.score_controversial(entity_id), Some(Sort::Controversial) => self.score_controversial(entity_id),
Some(Sort::HiddenGems) => self.score_hidden_gems(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) => { Some(Sort::New) => {
// M2 limitation: entity metadata (`created_at`) is not accessible from the // M2 limitation: entity metadata (`created_at`) is not accessible from the
// executor. Entity ID is used as a proxy for recency -- ranks higher IDs // 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). // precision loss for very large IDs, which is acceptable for ranking).
#[allow(clippy::cast_precision_loss)] #[allow(clippy::cast_precision_loss)]
let score = entity_id.as_u64() as f64; let score = entity_id.as_u64() as f64;
score (score, vec![])
} }
Some(Sort::TopWindow { window }) => self.score_top_window(entity_id, *window), Some(Sort::TopWindow { window }) => self.score_top_window(entity_id, *window),
Some(Sort::MostViewed { window }) => read_agg( Some(Sort::MostViewed { window }) => {
let val = read_agg(
entity_id, entity_id,
"view", "view",
&SignalAgg::Value, &SignalAgg::Value,
*window, *window,
self.ledger, self.ledger,
self.degradation_level, self.degradation_level,
), );
Some(Sort::MostLiked { window }) => read_agg( (val, vec![("view".to_string(), val)])
}
Some(Sort::MostLiked { window }) => {
let val = read_agg(
entity_id, entity_id,
"like", "like",
&SignalAgg::Value, &SignalAgg::Value,
*window, *window,
self.ledger, self.ledger,
self.degradation_level, self.degradation_level,
), );
Some(Sort::MostFollowed) => read_agg( (val, vec![("like".to_string(), val)])
}
Some(Sort::MostFollowed) => {
let val = read_agg(
entity_id, entity_id,
"follow", "follow",
&SignalAgg::Value, &SignalAgg::Value,
Window::AllTime, Window::AllTime,
self.ledger, self.ledger,
self.degradation_level, self.degradation_level,
), );
(val, vec![("follow".to_string(), val)])
}
Some(Sort::CreatorEngagementRate) => { Some(Sort::CreatorEngagementRate) => {
let view_vel = read_agg( let view_vel = read_agg(
entity_id, entity_id,
@ -81,43 +93,63 @@ impl ProfileExecutor<'_> {
self.ledger, self.ledger,
self.degradation_level, 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::Rising) => self.score_rising(entity_id),
Some(Sort::AlphabeticalAsc) => self.score_alphabetical_asc(entity_id), Some(Sort::AlphabeticalAsc) => (self.score_alphabetical_asc(entity_id), vec![]),
Some(Sort::AlphabeticalDesc) => self.score_alphabetical_desc(entity_id), Some(Sort::AlphabeticalDesc) => (self.score_alphabetical_desc(entity_id), vec![]),
Some(Sort::Shortest) => self.score_shortest(entity_id), Some(Sort::Shortest) => (self.score_shortest(entity_id), vec![]),
Some(Sort::Longest) => self.score_longest(entity_id), Some(Sort::Longest) => (self.score_longest(entity_id), vec![]),
Some(Sort::MostCommented { window }) => read_agg( Some(Sort::MostCommented { window }) => {
let val = read_agg(
entity_id, entity_id,
"comment", "comment",
&SignalAgg::Value, &SignalAgg::Value,
*window, *window,
self.ledger, self.ledger,
self.degradation_level, self.degradation_level,
), );
Some(Sort::MostShared { window }) => read_agg( (val, vec![("comment".to_string(), val)])
}
Some(Sort::MostShared { window }) => {
let val = read_agg(
entity_id, entity_id,
"share", "share",
&SignalAgg::Value, &SignalAgg::Value,
*window, *window,
self.ledger, self.ledger,
self.degradation_level, self.degradation_level,
), );
Some(Sort::LiveViewerCount) => read_agg( (val, vec![("share".to_string(), val)])
}
Some(Sort::LiveViewerCount) => {
let val = read_agg(
entity_id, entity_id,
"viewer_count", "viewer_count",
&SignalAgg::DecayScore, &SignalAgg::DecayScore,
Window::AllTime, Window::AllTime,
self.ledger, self.ledger,
self.degradation_level, self.degradation_level,
), );
Some(Sort::DateSaved) => self.score_date_saved(entity_id), (val, vec![("viewer_count".to_string(), val)])
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( let views = read_agg(
entity_id, entity_id,
"view", "view",
@ -132,10 +164,10 @@ impl ProfileExecutor<'_> {
// therefore ranks solely by view count at M2 scale. Per-entity age will be // 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+). // wired in when `TidalDb::read_item()` is plumbed through the executor (M3+).
let age_hours = 24.0_f64; 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 // M6: social-graph-scoped trending. When a social subgraph and
// per-user signal index are available, compute aggregate velocity // per-user signal index are available, compute aggregate velocity
// across the subgraph users instead of using the global ledger. // 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| { let share_vel = share_type_id.map_or(0.0, |tid| {
user_signal_idx.aggregate_velocity(entity_id, users, tid, Window::TwentyFourHours) 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. // Fallback: global ledger velocity.
@ -172,10 +210,16 @@ impl ProfileExecutor<'_> {
self.ledger, self.ledger,
self.degradation_level, 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( let pos = read_agg(
entity_id, entity_id,
"like", "like",
@ -192,10 +236,13 @@ impl ProfileExecutor<'_> {
self.ledger, self.ledger,
self.degradation_level, 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( let quality = read_agg(
entity_id, entity_id,
"completion", "completion",
@ -212,10 +259,16 @@ impl ProfileExecutor<'_> {
self.ledger, self.ledger,
self.degradation_level, 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( let views = read_agg(
entity_id, entity_id,
"view", "view",
@ -248,13 +301,21 @@ impl ProfileExecutor<'_> {
self.ledger, self.ledger,
self.degradation_level, self.degradation_level,
); );
(
views.mul_add( views.mul_add(
0.3, 0.3,
likes.mul_add(0.3, shares.mul_add(0.2, completion * views * 0.1)), 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( let short = read_agg(
entity_id, entity_id,
"view", "view",
@ -271,11 +332,14 @@ impl ProfileExecutor<'_> {
self.ledger, self.ledger,
self.degradation_level, self.degradation_level,
); );
if long < f64::EPSILON { let score = if long < f64::EPSILON { short } else { short / long };
short (
} else { score,
short / long 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 /// Score for `AlphabeticalAsc`: pack first 8 bytes of lowercased title into a