//! 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 { 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:?}" ); } }