tidaldb/tidal/tests/m6p6_creator_profile.rs
2026-02-23 22:41:16 -07:00

404 lines
13 KiB
Rust

//! Milestone 6 Phase 6 Integration Tests: Creator Profile Modes + Notification Caps.
//!
//! Tests:
//! 1. `for_creator` + `for_you` ranks creator's items by user preferences.
//! 2. `for_creator` + `hot` ranks hot-sorted items within creator's catalog.
//! 3. Notification caps limit per-creator results.
//! 4. Notification caps enforce total daily limit.
//! 5. Cold-start user sees creator top content.
//! 6. Regression: `for_creator` with high-ID items uses `CreatorItemsBitmap`.
#![allow(
clippy::unwrap_used,
clippy::cast_precision_loss,
clippy::similar_names
)]
use std::collections::HashMap;
use std::time::Duration;
use tidaldb::TidalDb;
use tidaldb::query::retrieve::{NotificationCaps, Retrieve};
use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window};
// ── Schema ──────────────────────────────────────────────────────────────────
fn test_schema() -> tidaldb::schema::Schema {
let mut builder = SchemaBuilder::new();
for &(name, half_life_days) in &[
("view", 7),
("like", 14),
("share", 7),
("skip", 1),
("completion", 30),
] {
let _ = builder
.signal(
name,
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(half_life_days * 24 * 3600),
},
)
.windows(&[
Window::OneHour,
Window::TwentyFourHours,
Window::SevenDays,
Window::AllTime,
])
.velocity(true)
.add();
}
builder.build().expect("schema must be valid")
}
fn open_ephemeral() -> TidalDb {
TidalDb::builder()
.ephemeral()
.with_schema(test_schema())
.open()
.expect("ephemeral open")
}
fn write_items_for_creator(db: &TidalDb, creator_id: u64, item_ids: &[u64]) {
for &id in item_ids {
let mut meta = HashMap::new();
meta.insert("title".to_string(), format!("Item {id}"));
meta.insert("category".to_string(), "jazz".to_string());
meta.insert("format".to_string(), "audio".to_string());
meta.insert("creator_id".to_string(), creator_id.to_string());
db.write_item_with_metadata(EntityId::new(id), &meta)
.unwrap();
}
}
// ── Test 1: for_creator + for_you ───────────────────────────────────────────
#[test]
fn for_creator_for_you_ranks_within_creator_catalog() {
let db = open_ephemeral();
// Creator A has items 1-5.
write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]);
// Creator B has items 6-10.
write_items_for_creator(&db, 200, &[6, 7, 8, 9, 10]);
// Signal items 1 and 2 heavily for view (creator A).
let now = Timestamp::now();
for _ in 0..20 {
db.signal("view", EntityId::new(1), 1.0, now).unwrap();
db.signal("view", EntityId::new(2), 1.0, now).unwrap();
}
// Query for_creator(100) + for_you profile + for_user(42).
let query = Retrieve::builder()
.profile("for_you")
.for_user(42)
.for_creator(EntityId::new(100))
.limit(10)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
// Only creator A's items should appear.
for item in &results.items {
let id = item.entity_id.as_u64();
assert!(
(1..=5).contains(&id),
"expected item from creator A, got id={id}"
);
}
assert!(!results.items.is_empty(), "should return some items");
}
// ── Test 2: for_creator + hot ───────────────────────────────────────────────
#[test]
fn for_creator_hot_ranks_by_hotness_within_catalog() {
let db = open_ephemeral();
// Creator A: items 1-5.
write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]);
// Creator B: items 6-10.
write_items_for_creator(&db, 200, &[6, 7, 8, 9, 10]);
// Item 1 gets many recent views, item 2 gets few.
let now = Timestamp::now();
for _ in 0..50 {
db.signal("view", EntityId::new(1), 1.0, now).unwrap();
}
db.signal("view", EntityId::new(2), 1.0, now).unwrap();
// Query for_creator(100) + hot profile.
let query = Retrieve::builder()
.profile("hot")
.for_creator(EntityId::new(100))
.limit(10)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
// Only creator A's items should appear.
for item in &results.items {
let id = item.entity_id.as_u64();
assert!(
(1..=5).contains(&id),
"expected item from creator A, got id={id}"
);
}
assert!(!results.items.is_empty(), "should return some items");
}
// ── Test 3: notification caps per-creator ───────────────────────────────────
#[test]
fn notification_caps_enforce_per_creator_limit() {
let db = open_ephemeral();
// Write 5 items from creator A and 5 from creator B.
write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]);
write_items_for_creator(&db, 200, &[6, 7, 8, 9, 10]);
// User 1 follows both creators (required for notification profile).
db.write_relationship(
EntityId::new(1),
tidaldb::entities::RelationshipType::Follows,
EntityId::new(100),
1.0,
Timestamp::now(),
)
.unwrap();
db.write_relationship(
EntityId::new(1),
tidaldb::entities::RelationshipType::Follows,
EntityId::new(200),
1.0,
Timestamp::now(),
)
.unwrap();
// Signal all items with views so they rank.
let now = Timestamp::now();
for id in 1..=10u64 {
for _ in 0..5 {
db.signal("view", EntityId::new(id), 1.0, now).unwrap();
}
}
// Retrieve with notification caps: max 1 per creator, max 3 total.
let query = Retrieve::builder()
.profile("notification")
.for_user(1)
.notification_caps(NotificationCaps {
max_per_creator_per_day: 1,
max_total_per_day: 3,
})
.limit(20)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
// Verify total <= 3.
assert!(
results.items.len() <= 3,
"expected at most 3 results, got {}",
results.items.len()
);
// Verify no creator appears more than once (need to check creator_id
// from item metadata -- but we can verify by item ID ranges).
let creator_a_count = results
.items
.iter()
.filter(|r| r.entity_id.as_u64() <= 5)
.count();
let creator_b_count = results
.items
.iter()
.filter(|r| r.entity_id.as_u64() > 5)
.count();
assert!(
creator_a_count <= 1,
"expected at most 1 item from creator A, got {creator_a_count}"
);
assert!(
creator_b_count <= 1,
"expected at most 1 item from creator B, got {creator_b_count}"
);
}
// ── Test 4 (UC-08): cold-start user sees top content on creator page ─────────
/// A user with no preference history (cold-start) queries a creator's catalog
/// via `for_creator`. The system should still return results -- falling back to
/// engagement signals (e.g., view counts) rather than returning empty.
///
/// This validates the UC-08 cold-start behavior: new visitors to a creator's
/// profile page see the creator's most engaging content even without
/// personalization context.
#[test]
fn cold_start_user_sees_creator_top_content() {
let db = open_ephemeral();
// Creator has 5 items.
write_items_for_creator(&db, 100, &[1, 2, 3, 4, 5]);
// Signal items 1 and 2 heavily so they rank at the top.
let now = Timestamp::now();
for _ in 0..30 {
db.signal("view", EntityId::new(1), 1.0, now).unwrap();
db.signal("view", EntityId::new(2), 1.0, now).unwrap();
}
// User 999 has never interacted with the system -- pure cold-start.
// Deliberately no db.write_item(EntityId::new(999)) or any signals.
let query = Retrieve::builder()
.profile("for_you")
.for_user(999)
.for_creator(EntityId::new(100))
.limit(10)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
// Cold-start must not return empty -- the creator has items and signals.
assert!(
!results.items.is_empty(),
"cold-start user should still see creator content"
);
// All results must belong to creator 100 (items 1-5).
for item in &results.items {
let id = item.entity_id.as_u64();
assert!(
(1..=5).contains(&id),
"expected item from creator 100, got id={id}"
);
}
}
// ── Test 5: notification caps total daily limit ─────────────────────────────
#[test]
fn notification_caps_enforce_total_limit() {
let db = open_ephemeral();
// 10 items from 10 different creators.
for id in 1..=10u64 {
write_items_for_creator(&db, id * 100, &[id]);
// User follows each creator.
db.write_relationship(
EntityId::new(1),
tidaldb::entities::RelationshipType::Follows,
EntityId::new(id * 100),
1.0,
Timestamp::now(),
)
.unwrap();
}
// Signal all items.
let now = Timestamp::now();
for id in 1..=10u64 {
for _ in 0..5 {
db.signal("view", EntityId::new(id), 1.0, now).unwrap();
}
}
let query = Retrieve::builder()
.profile("notification")
.for_user(1)
.notification_caps(NotificationCaps {
max_per_creator_per_day: 2,
max_total_per_day: 3,
})
.limit(20)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
assert!(
results.items.len() <= 3,
"expected at most 3 total results, got {}",
results.items.len()
);
}
// ── Test 6: Regression -- for_creator uses CreatorItemsBitmap for high IDs ──
/// Regression test for the bug where `for_creator` was set by the builder
/// but never read by the executor. Stage 1 always called `scan_candidates`
/// which caps at `max(limit * multiplier, 200)` items from the universe
/// bitmap by ascending ID. For a large catalog where a creator's items have
/// IDs above that cap, those items were never generated as candidates.
///
/// This test writes 300 items from creator 999 at IDs 201-500 (above the
/// scan cap of 200), plus 200 filler items from other creators at IDs 1-200.
/// Before the fix, `for_creator(999)` would return empty because the scan
/// cap never reached IDs > 200. After the fix, the `CreatorItemsBitmap`
/// is used for O(k) candidate generation, returning all of creator 999's items.
#[test]
fn for_creator_high_id_items_uses_creator_bitmap() {
let db = open_ephemeral();
// Write 200 filler items from other creators (IDs 1-200).
for id in 1..=200u64 {
let mut meta = HashMap::new();
meta.insert("title".to_string(), format!("Filler {id}"));
meta.insert("category".to_string(), "pop".to_string());
meta.insert("format".to_string(), "audio".to_string());
meta.insert("creator_id".to_string(), "1".to_string());
db.write_item_with_metadata(EntityId::new(id), &meta)
.unwrap();
}
// Write 300 items from creator 999 at IDs 201-500.
let creator_item_ids: Vec<u64> = (201..=500).collect();
write_items_for_creator(&db, 999, &creator_item_ids);
// Query for_creator(999) with limit=50.
let query = Retrieve::builder()
.profile("new")
.for_creator(EntityId::new(999))
.limit(50)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
// Must return results -- the bitmap path finds all 300 items regardless
// of scan cap.
assert!(
!results.items.is_empty(),
"for_creator must return results even when all items have IDs above the scan cap"
);
// All returned items must belong to creator 999 (IDs 201-500).
for item in &results.items {
let id = item.entity_id.as_u64();
assert!(
(201..=500).contains(&id),
"expected item from creator 999 (IDs 201-500), got id={id}"
);
}
// Verify we get up to limit items (creator has 300 items, limit=50).
assert_eq!(
results.items.len(),
50,
"should return exactly limit=50 items from the 300 available"
);
// Also verify unknown creator returns empty, not an error.
let query_unknown = Retrieve::builder()
.profile("new")
.for_creator(EntityId::new(99999))
.limit(10)
.build()
.unwrap();
let results_unknown = db.retrieve(&query_unknown).unwrap();
assert!(
results_unknown.items.is_empty(),
"unknown creator should return empty results"
);
}