tidaldb/tidal/tests/p1_quality_diversity.rs

393 lines
13 KiB
Rust

#![allow(clippy::unwrap_used)]
//! P1 Quality & Diversity Baseline integration tests.
//!
//! Proves that the `brief` built-in ranking profile enforces quality gates
//! (minimum view count, minimum completion count) and diversity constraints
//! (max-per-creator, format-mix) on the daily brief surface.
use std::collections::HashMap;
use std::time::Duration;
use tidaldb::TidalDb;
use tidaldb::query::retrieve::Retrieve;
use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window};
// ── Test helpers ────────────────────────────────────────────────────────────
/// Build a schema with `view`, `completion`, and `like` signals — the minimum
/// required for the `brief` profile's gates and boosts.
fn brief_schema() -> tidaldb::schema::Schema {
let mut builder = SchemaBuilder::new();
for sig in &["view", "completion", "like"] {
let _ = builder
.signal(
sig,
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::OneHour, Window::TwentyFourHours])
.velocity(false)
.add();
}
builder.build().unwrap()
}
/// Open an ephemeral TidalDb with the brief-compatible schema.
fn test_db() -> TidalDb {
TidalDb::builder()
.ephemeral()
.with_schema(brief_schema())
.open()
.unwrap()
}
/// Write an item with the given creator_id and format metadata.
fn write_item(db: &TidalDb, id: u64, creator_id: u64, format: &str) {
let mut meta = HashMap::new();
meta.insert("title".to_string(), format!("item-{id}"));
meta.insert("creator_id".to_string(), creator_id.to_string());
meta.insert("format".to_string(), format.to_string());
db.write_item_with_metadata(EntityId::new(id), &meta)
.unwrap();
}
/// Record `n` view signals for an item.
fn record_views(db: &TidalDb, id: u64, n: u64) {
let ts = Timestamp::now();
for _ in 0..n {
db.signal("view", EntityId::new(id), 1.0, ts).unwrap();
}
}
/// Record `n` completion signals for an item.
fn record_completions(db: &TidalDb, id: u64, n: u64) {
let ts = Timestamp::now();
for _ in 0..n {
db.signal("completion", EntityId::new(id), 1.0, ts).unwrap();
}
}
/// Write a high-quality item (passes both view and completion gates).
fn write_quality_item(db: &TidalDb, id: u64, creator_id: u64, format: &str) {
write_item(db, id, creator_id, format);
record_views(db, id, 10);
record_completions(db, id, 5);
}
/// Execute a RETRIEVE using the `brief` profile with the given limit.
fn retrieve_brief(db: &TidalDb, limit: usize) -> tidaldb::query::retrieve::Results {
let query = Retrieve::builder()
.profile("brief")
.limit(limit)
.build()
.unwrap();
db.retrieve(&query).unwrap()
}
// ── Test 1: Quality gate excludes low-view items ────────────────────────────
#[test]
fn quality_gate_excludes_low_view_items() {
let db = test_db();
// 5 items with >= 3 views and >= 1 completion (pass both gates).
for id in 1..=5 {
write_item(&db, id, id, "video");
record_views(&db, id, 5);
record_completions(&db, id, 2);
}
// 5 items with only 1-2 views (fail view gate, threshold = 3).
for id in 6..=10 {
write_item(&db, id, id, "video");
record_views(&db, id, if id % 2 == 0 { 1 } else { 2 });
record_completions(&db, id, 1);
}
let results = retrieve_brief(&db, 20);
let result_ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
// Only items 1-5 should appear (they pass the view >= 3 gate).
for id in 1..=5 {
assert!(
result_ids.contains(&id),
"item {id} should be in results (has >= 3 views)"
);
}
for id in 6..=10 {
assert!(
!result_ids.contains(&id),
"item {id} should NOT be in results (has < 3 views)"
);
}
}
// ── Test 2: Quality gate excludes zero-completion items ─────────────────────
#[test]
fn quality_gate_excludes_zero_completion_items() {
let db = test_db();
// 5 items with >= 3 views and >= 1 completion (pass both gates).
for id in 1..=5 {
write_item(&db, id, id, "podcast");
record_views(&db, id, 10);
record_completions(&db, id, 3);
}
// 5 items with >= 3 views but 0 completions (fail completion gate).
for id in 6..=10 {
write_item(&db, id, id, "podcast");
record_views(&db, id, 10);
// No completions recorded.
}
let results = retrieve_brief(&db, 20);
let result_ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
for id in 1..=5 {
assert!(
result_ids.contains(&id),
"item {id} should be in results (has completions)"
);
}
for id in 6..=10 {
assert!(
!result_ids.contains(&id),
"item {id} should NOT be in results (zero completions)"
);
}
}
// ── Test 3: Creator diversity enforced ──────────────────────────────────────
#[test]
fn creator_diversity_enforced() {
let db = test_db();
// The diversity selector uses multi-stage relaxation with
// target_count = scored.len(). When scored.len() > limit, diversity
// ordering promotes variety in the paginated slice.
//
// Create 30 items: 10 from creator 1 (would dominate), 5 from each
// of creators 2-5. All pass quality gates. With scored.len()=30 and
// limit=10, the selector runs greedy selection.
//
// Stage 0 (max_per_creator=2): accepts 2 from each of 5 creators = 10.
// Remaining 20 fill via stages 1-3. The final score-ordered list has
// stage-0 items first (diverse), followed by relaxed items.
//
// The first 10 (paginated result) should show diversity: multiple
// creators represented, no single creator having all 10.
for id in 1..=10 {
write_quality_item(&db, id, 1, "video");
}
for id in 11..=15 {
write_quality_item(&db, id, 2, "video");
}
for id in 16..=20 {
write_quality_item(&db, id, 3, "video");
}
for id in 21..=25 {
write_quality_item(&db, id, 4, "video");
}
for id in 26..=30 {
write_quality_item(&db, id, 5, "video");
}
let results = retrieve_brief(&db, 10);
// Count items per creator in results.
let mut creator_counts: HashMap<u64, usize> = HashMap::new();
for result in &results.items {
let eid = result.entity_id.as_u64();
let creator = match eid {
1..=10 => 1,
11..=15 => 2,
16..=20 => 3,
21..=25 => 4,
26..=30 => 5,
_ => 0,
};
if creator > 0 {
*creator_counts.entry(creator).or_insert(0) += 1;
}
}
// With diversity enforcement, no single creator should dominate the
// top 10. max_per_creator=2 at stage 0, doubled to 4 at stage 1.
// The paginated top-10 should have at most ~4 per creator.
for (&creator, &count) in &creator_counts {
assert!(
count <= 5,
"creator {creator} has {count}/10 items; diversity should limit this"
);
}
// Multiple creators should be represented in the top 10.
assert!(
creator_counts.len() >= 2,
"diversity should ensure multiple creators in results; got {creator_counts:?}"
);
}
// ── Test 4: Format diversity enforced ───────────────────────────────────────
#[test]
fn format_diversity_enforced() {
let db = test_db();
// With format_mix_max_fraction=0.6 and limit=10, at most 6 items of any
// single format are allowed. Provide enough diverse formats so that
// relaxation is not needed.
//
// 6 "video" items + 6 "podcast" items = 12 total, all different creators.
// With limit=10 and max_per_creator=2, need >= 5 creators.
for id in 1..=6 {
write_quality_item(&db, id, id, "video");
}
for id in 7..=12 {
write_quality_item(&db, id, id, "podcast");
}
let results = retrieve_brief(&db, 10);
// Count formats in results.
let video_count = results
.items
.iter()
.filter(|r| r.entity_id.as_u64() <= 6)
.count();
let podcast_count = results
.items
.iter()
.filter(|r| r.entity_id.as_u64() > 6)
.count();
// format_mix_max_fraction = 0.6: max 60% of results can be any single format.
let total = results.items.len();
let max_per_format = ((total as f64) * 0.6).floor() as usize;
assert!(
video_count <= max_per_format.max(1),
"video count {video_count} exceeds max {max_per_format} (60% of {total})"
);
assert!(
podcast_count <= max_per_format.max(1),
"podcast count {podcast_count} exceeds max {max_per_format} (60% of {total})"
);
}
// ── Test 5: Brief profile is registered ─────────────────────────────────────
#[test]
fn brief_profile_registered() {
// Opening a TidalDb registers all builtins including `brief`.
let db = test_db();
// Retrieve using the brief profile to confirm it exists.
// Even with no items, the query should execute without "ProfileNotFound".
let query = Retrieve::builder()
.profile("brief")
.limit(10)
.build()
.unwrap();
let results = db.retrieve(&query).unwrap();
// No items in the DB, so results should be empty.
assert!(results.items.is_empty());
}
// ── Test 6: Combined quality and diversity ──────────────────────────────────
#[test]
fn combined_quality_and_diversity() {
let db = test_db();
// Mixed quality, diverse creators, mixed formats.
// High-quality items (pass gates): 5 creators, 2 items each = 10 items.
// Low-quality items (fail gates): should be excluded.
//
// Creator 1: items 1-2, video, high quality
// Creator 2: items 3-4, video, high quality
// Creator 3: items 5-6, podcast, high quality
// Creator 4: items 7-8, podcast, high quality
// Creator 5: items 9-10, video, high quality
// Creator 6: items 11-12, video, LOW quality (fail gates)
// Creator 7: item 13, podcast, LOW quality (fail gates)
for id in 1..=2 {
write_quality_item(&db, id, 1, "video");
}
for id in 3..=4 {
write_quality_item(&db, id, 2, "video");
}
for id in 5..=6 {
write_quality_item(&db, id, 3, "podcast");
}
for id in 7..=8 {
write_quality_item(&db, id, 4, "podcast");
}
for id in 9..=10 {
write_quality_item(&db, id, 5, "video");
}
// Low quality: 1 view, no completions
for id in 11..=12 {
write_item(&db, id, 6, "video");
record_views(&db, id, 1);
}
write_item(&db, 13, 7, "podcast");
record_views(&db, 13, 2);
let results = retrieve_brief(&db, 10);
let result_ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
// Quality gate: low-quality items should NOT appear.
for id in 11..=13 {
assert!(
!result_ids.contains(&id),
"low-quality item {id} should be excluded from brief"
);
}
// Creator diversity: count per creator.
let mut creator_counts: HashMap<u64, usize> = HashMap::new();
for &id in &result_ids {
let creator = match id {
1..=2 => 1,
3..=4 => 2,
5..=6 => 3,
7..=8 => 4,
9..=10 => 5,
_ => 0,
};
if creator > 0 {
*creator_counts.entry(creator).or_insert(0) += 1;
}
}
for (&creator, &count) in &creator_counts {
assert!(
count <= 2,
"creator {creator} has {count} items in results, expected <= 2"
);
}
// Format diversity: no format should exceed 60% of results.
let video_count = result_ids
.iter()
.filter(|&&id| matches!(id, 1..=4 | 9..=10))
.count();
let total = results.items.len();
if total > 0 {
let max_per_format = ((total as f64) * 0.6).floor() as usize;
assert!(
video_count <= max_per_format.max(1),
"video count {video_count} exceeds 60% of {total} results"
);
}
}