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