Merge remote-tracking branch 'origin/main'

This commit is contained in:
jordan 2026-03-16 06:05:09 -06:00
commit 9eecb3ef0b
3 changed files with 168 additions and 81 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

@ -445,24 +445,21 @@ impl<'a> ProfileExecutor<'a> {
rev_idx.is_suppressed(user_id, signal_name, signal_ts_ns, None) 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 /// Returns `(score, snapshot)` where `snapshot` lists the raw signal values
/// the aggregated value for each boost signal that contributed to the score. /// that contributed, for explain-ability in API responses.
/// This enables explainability 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, Vec<(String, 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);
// Collect boost signal values for the signal snapshot. // Apply boosts and capture their contributions in the snapshot,
let mut snapshot = Vec::with_capacity(profile.boosts.len()); // skipping any whose signal is suppressed by community policy
// or by the requesting user's active revocations.
// Apply boosts, skipping any whose signal is suppressed by community
// policy or by the requesting user's active revocations.
let boost_sum: f64 = profile let boost_sum: f64 = profile
.boosts .boosts
.iter() .iter()
@ -477,10 +474,11 @@ impl<'a> ProfileExecutor<'a> {
self.ledger, self.ledger,
self.degradation_level, self.degradation_level,
); );
if val > 0.0 { let weighted = b.weight * val;
snapshot.push((b.signal.clone(), val)); if weighted != 0.0 {
snapshot.push((format!("{}_boost", b.signal), weighted));
} }
b.weight * val weighted
}) })
.sum(); .sum();
@ -498,11 +496,11 @@ 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)
{ {
let score = f64::from(co_eng.score(seed, entity_id)); let boost = f64::from(co_eng.score(seed, entity_id)) * 0.3;
if score > 0.0 { if boost != 0.0 {
snapshot.push(("co_engagement".to_owned(), score)); snapshot.push(("co_engagement".to_string(), boost));
} }
score * 0.3 boost
} else { } else {
0.0 0.0
}; };

View File

@ -34,18 +34,21 @@ impl ProfileExecutor<'_> {
self.is_suppressed(signal) || self.is_signal_revoked_for_entity(entity_id, signal) 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)] #[allow(clippy::too_many_lines)]
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 }) => { Some(Sort::Hot { gravity }) => {
if self.is_signal_blocked(entity_id, "view") { if self.is_signal_blocked(entity_id, "view") {
0.0 (0.0, vec![])
} else { } else {
self.score_hot(entity_id, *gravity, now) self.score_hot(entity_id, *gravity, now)
} }
@ -54,7 +57,7 @@ impl ProfileExecutor<'_> {
if self.is_signal_blocked(entity_id, "view") if self.is_signal_blocked(entity_id, "view")
&& self.is_signal_blocked(entity_id, "share") && self.is_signal_blocked(entity_id, "share")
{ {
0.0 (0.0, vec![])
} else { } else {
self.score_trending(entity_id) self.score_trending(entity_id)
} }
@ -63,13 +66,13 @@ impl ProfileExecutor<'_> {
if self.is_signal_blocked(entity_id, "like") if self.is_signal_blocked(entity_id, "like")
&& self.is_signal_blocked(entity_id, "dislike") && self.is_signal_blocked(entity_id, "dislike")
{ {
0.0 (0.0, vec![])
} else { } else {
self.score_controversial(entity_id) 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
@ -81,49 +84,52 @@ 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 }) => { Some(Sort::MostViewed { window }) => {
if self.is_signal_blocked(entity_id, "view") { if self.is_signal_blocked(entity_id, "view") {
0.0 (0.0, vec![])
} else { } else {
read_agg( 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,
) );
(val, vec![("view".to_string(), val)])
} }
} }
Some(Sort::MostLiked { window }) => { Some(Sort::MostLiked { window }) => {
if self.is_signal_blocked(entity_id, "like") { if self.is_signal_blocked(entity_id, "like") {
0.0 (0.0, vec![])
} else { } else {
read_agg( 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,
) );
(val, vec![("like".to_string(), val)])
} }
} }
Some(Sort::MostFollowed) => { Some(Sort::MostFollowed) => {
if self.is_signal_blocked(entity_id, "follow") { if self.is_signal_blocked(entity_id, "follow") {
0.0 (0.0, vec![])
} else { } else {
read_agg( 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) => {
@ -151,61 +157,75 @@ impl ProfileExecutor<'_> {
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 }) => { Some(Sort::MostCommented { window }) => {
if self.is_signal_blocked(entity_id, "comment") { if self.is_signal_blocked(entity_id, "comment") {
0.0 (0.0, vec![])
} else { } else {
read_agg( 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,
) );
(val, vec![("comment".to_string(), val)])
} }
} }
Some(Sort::MostShared { window }) => { Some(Sort::MostShared { window }) => {
if self.is_signal_blocked(entity_id, "share") { if self.is_signal_blocked(entity_id, "share") {
0.0 (0.0, vec![])
} else { } else {
read_agg( 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,
) );
(val, vec![("share".to_string(), val)])
} }
} }
Some(Sort::LiveViewerCount) => { Some(Sort::LiveViewerCount) => {
if self.is_signal_blocked(entity_id, "viewer_count") { if self.is_signal_blocked(entity_id, "viewer_count") {
0.0 (0.0, vec![])
} else { } else {
read_agg( 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,
) );
(val, vec![("viewer_count".to_string(), val)])
} }
} }
Some(Sort::DateSaved) => self.score_date_saved(entity_id), Some(Sort::DateSaved) => (self.score_date_saved(entity_id), vec![]),
None => 0.0, 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",
@ -220,10 +240,13 @@ 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.
@ -240,7 +263,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.
@ -260,10 +289,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",
@ -280,10 +315,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",
@ -300,10 +338,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",
@ -336,13 +380,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",
@ -359,11 +411,18 @@ impl ProfileExecutor<'_> {
self.ledger, self.ledger,
self.degradation_level, self.degradation_level,
); );
if long < f64::EPSILON { let score = if long < f64::EPSILON {
short short
} else { } else {
short / long 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 /// Score for `AlphabeticalAsc`: pack first 8 bytes of lowercased title into a