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 ────────────────────────────────────
//
// 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
);

View File

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

View File

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