#![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 = 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 = 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 = 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 = 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 = 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" ); } }