tidaldb/tidal/tests/p1_reason_labels.rs

401 lines
13 KiB
Rust

//! P1: Reason Labels integration tests.
//!
//! Validates that reason labels are correctly populated on query results
//! for different profile types and search modes. Each test verifies that
//! the `reasons` field contains the expected `ReasonCode` values.
#![allow(clippy::unwrap_used, clippy::cast_precision_loss)]
use std::collections::HashMap;
use std::time::Duration;
use tidaldb::query::retrieve::{ProfileRef, RetrieveBuilder};
use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window};
use tidaldb::{ReasonCode, TidalDb};
// ── Schema ──────────────────────────────────────────────────────────────────
fn reason_schema() -> tidaldb::schema::Schema {
let mut builder = SchemaBuilder::new();
for sig in &["view", "like", "share", "completion", "dislike", "follow"] {
let _ = builder
.signal(
sig,
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[
Window::OneHour,
Window::TwentyFourHours,
Window::SevenDays,
Window::AllTime,
])
.velocity(true)
.add();
}
builder.build().unwrap()
}
fn item_meta(creator_id: u64) -> HashMap<String, String> {
let mut m = HashMap::new();
m.insert("creator_id".to_string(), creator_id.to_string());
m.insert("title".to_string(), format!("Item by creator {creator_id}"));
m.insert("category".to_string(), "jazz".to_string());
m.insert("format".to_string(), "audio".to_string());
m
}
fn seed_db(db: &TidalDb, count: u64) {
let ts = Timestamp::now();
for i in 1..=count {
db.write_item_with_metadata(EntityId::new(i), &item_meta(100 + (i % 3)))
.unwrap();
// Variable signal counts so items have different scores.
for _ in 0..i {
db.signal("view", EntityId::new(i), 1.0, ts).unwrap();
}
if i % 2 == 0 {
db.signal("like", EntityId::new(i), 1.0, ts).unwrap();
}
if i % 3 == 0 {
db.signal("share", EntityId::new(i), 1.0, ts).unwrap();
}
}
}
// ── Test 1: trending profile -> TrendingGlobal ──────────────────────────────
#[test]
fn trending_profile_carries_trending_global_reason() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(reason_schema())
.open()
.unwrap();
seed_db(&db, 10);
let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("trending"))
.limit(5)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
assert!(!results.items.is_empty(), "should return results");
for item in &results.items {
let codes: Vec<&ReasonCode> = item.reasons.iter().map(|r| &r.code).collect();
assert!(
codes.contains(&&ReasonCode::TrendingGlobal),
"trending result should carry TrendingGlobal reason, got: {codes:?}"
);
}
}
// ── Test 2: hidden_gems profile -> HiddenGem ────────────────────────────────
#[test]
fn hidden_gems_profile_carries_hidden_gem_reason() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(reason_schema())
.open()
.unwrap();
let ts = Timestamp::now();
// Create items with high completion but low views (hidden gems pattern).
for i in 1..=5 {
db.write_item_with_metadata(EntityId::new(i), &item_meta(100))
.unwrap();
db.signal("completion", EntityId::new(i), 1.0, ts).unwrap();
// Only 1 view each (low exposure).
db.signal("view", EntityId::new(i), 1.0, ts).unwrap();
}
let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("hidden_gems"))
.limit(5)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
assert!(!results.items.is_empty(), "should return results");
for item in &results.items {
let codes: Vec<&ReasonCode> = item.reasons.iter().map(|r| &r.code).collect();
assert!(
codes.contains(&&ReasonCode::HiddenGem),
"hidden_gems result should carry HiddenGem reason, got: {codes:?}"
);
}
}
// ── Test 3: hot profile -> TrendingGlobal ────────────────────────────────────
#[test]
fn hot_profile_carries_trending_global_reason() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(reason_schema())
.open()
.unwrap();
seed_db(&db, 10);
let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("hot"))
.limit(5)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
assert!(!results.items.is_empty(), "should return results");
for item in &results.items {
let codes: Vec<&ReasonCode> = item.reasons.iter().map(|r| &r.code).collect();
assert!(
codes.contains(&&ReasonCode::TrendingGlobal),
"hot result should carry TrendingGlobal reason, got: {codes:?}"
);
}
}
// ── Test 4: controversial profile -> Controversial ──────────────────────────
#[test]
fn controversial_profile_carries_controversial_reason() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(reason_schema())
.open()
.unwrap();
let ts = Timestamp::now();
for i in 1..=5 {
db.write_item_with_metadata(EntityId::new(i), &item_meta(100))
.unwrap();
// Mixed positive and negative signals.
for _ in 0..5 {
db.signal("like", EntityId::new(i), 1.0, ts).unwrap();
db.signal("dislike", EntityId::new(i), 1.0, ts).unwrap();
}
}
let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("controversial"))
.limit(5)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
assert!(!results.items.is_empty(), "should return results");
for item in &results.items {
let codes: Vec<&ReasonCode> = item.reasons.iter().map(|r| &r.code).collect();
assert!(
codes.contains(&&ReasonCode::Controversial),
"controversial result should carry Controversial reason, got: {codes:?}"
);
}
}
// ── Test 5: following profile -> FollowedCreator ────────────────────────────
#[test]
fn following_profile_carries_followed_creator_reason() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(reason_schema())
.open()
.unwrap();
let ts = Timestamp::now();
let user_id = 999_u64;
let creator_id = 100_u64;
// Create items from a creator.
for i in 1..=5 {
let mut meta = item_meta(creator_id);
meta.insert("creator_id".to_string(), creator_id.to_string());
db.write_item_with_metadata(EntityId::new(i), &meta)
.unwrap();
db.signal("view", EntityId::new(i), 1.0, ts).unwrap();
}
// User follows the creator.
db.user_state().add_follow(user_id, creator_id);
let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("following"))
.for_user(user_id)
.limit(5)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
assert!(
!results.items.is_empty(),
"should return results from followed creator"
);
for item in &results.items {
let codes: Vec<&ReasonCode> = item.reasons.iter().map(|r| &r.code).collect();
assert!(
codes.contains(&&ReasonCode::FollowedCreator),
"following result should carry FollowedCreator reason, got: {codes:?}"
);
}
}
// ── Test 6: reason weight ordering ──────────────────────────────────────────
#[test]
fn reason_weights_are_descending() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(reason_schema())
.open()
.unwrap();
seed_db(&db, 10);
let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("trending"))
.limit(5)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
for item in &results.items {
if item.reasons.len() > 1 {
for pair in item.reasons.windows(2) {
assert!(
pair[0].weight >= pair[1].weight,
"reasons should be sorted by weight descending: {} >= {}",
pair[0].weight,
pair[1].weight
);
}
}
}
}
// ── Test 7: at most 3 reasons per result ────────────────────────────────────
#[test]
fn reasons_capped_at_three() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(reason_schema())
.open()
.unwrap();
seed_db(&db, 10);
let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("for_you"))
.for_user(42)
.limit(10)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
for item in &results.items {
assert!(
item.reasons.len() <= 3,
"each result should have at most 3 reasons, got {}",
item.reasons.len()
);
}
}
// ── Test 8: signal snapshot is populated ────────────────────────────────────
#[test]
fn signal_snapshot_populated_for_boosted_profiles() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(reason_schema())
.open()
.unwrap();
seed_db(&db, 10);
let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("for_you"))
.for_user(42)
.limit(5)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
assert!(!results.items.is_empty(), "should return results");
// At least some results should have non-empty signals (items with view/like/share signals).
// The for_you profile uses DecayScore boosts, which produce non-zero values
// immediately after signal writes (unlike Velocity which requires bucket rotation).
let has_signals = results.items.iter().any(|item| !item.signals.is_empty());
assert!(
has_signals,
"for_you profile results should have populated signal snapshots"
);
}
// ── Test 9: search text -> TextRelevance ────────────────────────────────────
#[test]
fn search_text_carries_text_relevance_reason() {
use tidaldb::query::search::Search;
let db = TidalDb::builder()
.ephemeral()
.with_schema(reason_schema())
.open()
.unwrap();
let ts = Timestamp::now();
for i in 1..=10 {
let mut meta = item_meta(100);
meta.insert(
"title".to_string(),
format!("Jazz improvisation lesson {i}"),
);
db.write_item_with_metadata(EntityId::new(i), &meta)
.unwrap();
db.signal("view", EntityId::new(i), 1.0, ts).unwrap();
}
// Wait for text indexer to commit, then reload.
std::thread::sleep(Duration::from_millis(2500));
let _ = db.reload_text_index();
let query = Search::builder()
.query("jazz improvisation")
.limit(5)
.build()
.unwrap();
let results = db.search(&query).unwrap();
if !results.items.is_empty() {
let has_text_relevance = results.items.iter().any(|item| {
item.reasons
.iter()
.any(|r| r.code == ReasonCode::TextRelevance)
});
assert!(
has_text_relevance,
"text search results should carry TextRelevance reason"
);
}
}
// ── Test 10: top_week profile -> TopInWindow ────────────────────────────────
#[test]
fn top_week_profile_carries_top_in_window_reason() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(reason_schema())
.open()
.unwrap();
seed_db(&db, 10);
let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("top_week"))
.limit(5)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
assert!(!results.items.is_empty(), "should return results");
for item in &results.items {
let codes: Vec<&ReasonCode> = item.reasons.iter().map(|r| &r.code).collect();
assert!(
codes.contains(&&ReasonCode::TopInWindow),
"top_week result should carry TopInWindow reason, got: {codes:?}"
);
}
}