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 ────────────────────────────────────
//
// 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<dyn std::error::Error>> {
// ── 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
);

View File

@ -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)
}
}

View File

@ -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