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:
parent
1d826c87b2
commit
006d3d058a
@ -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)?;
|
||||
// (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,
|
||||
_ => {}
|
||||
}
|
||||
for &item_id in &liked_items {
|
||||
db.signal("like", EntityId::new(item_id), 1.0, now)?;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
Some(Sort::MostViewed { window }) => {
|
||||
let val = read_agg(
|
||||
entity_id,
|
||||
"view",
|
||||
&SignalAgg::Value,
|
||||
*window,
|
||||
self.ledger,
|
||||
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,
|
||||
"like",
|
||||
&SignalAgg::Value,
|
||||
*window,
|
||||
self.ledger,
|
||||
self.degradation_level,
|
||||
),
|
||||
Some(Sort::MostFollowed) => read_agg(
|
||||
);
|
||||
(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(
|
||||
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,
|
||||
),
|
||||
Some(Sort::MostShared { window }) => read_agg(
|
||||
);
|
||||
(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,
|
||||
),
|
||||
Some(Sort::LiveViewerCount) => read_agg(
|
||||
);
|
||||
(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,
|
||||
),
|
||||
Some(Sort::DateSaved) => self.score_date_saved(entity_id),
|
||||
None => 0.0,
|
||||
);
|
||||
(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)),
|
||||
),
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user