401 lines
13 KiB
Rust
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:?}"
|
|
);
|
|
}
|
|
}
|