#![allow(clippy::unwrap_used)] //! M3 User Acceptance Test: Personalized Ranking. //! //! Proves the full feedback loop: user entities, relationships, preference //! vectors, hard negatives, interaction weights, signal dispatch, and //! personalized RETRIEVE queries with FOR USER clauses work end-to-end. use std::collections::HashMap; use std::time::Duration; use tidaldb::TidalDb; use tidaldb::entities::RelationshipType; use tidaldb::query::retrieve::{ProfileRef, RetrieveBuilder}; use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window}; fn test_schema() -> tidaldb::schema::Schema { let mut builder = SchemaBuilder::new(); for sig in &[ "view", "like", "share", "skip", "completion", "dislike", "hide", ] { let _ = builder .signal( sig, EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::OneHour, Window::TwentyFourHours, Window::SevenDays]) .velocity(true) .add(); } builder.build().unwrap() } fn test_db() -> TidalDb { TidalDb::builder() .ephemeral() .with_schema(test_schema()) .open() .unwrap() } // ── Test 1: User + Creator CRUD ──────────────────────────────────────────── #[test] fn user_and_creator_crud() { let db = test_db(); // Write a user. let mut user_meta = HashMap::new(); user_meta.insert("name".to_string(), "alice".to_string()); db.write_user(EntityId::new(1000), &user_meta).unwrap(); // Read it back. let meta = db.get_user_metadata(EntityId::new(1000)).unwrap().unwrap(); assert_eq!(meta.get("name").unwrap(), "alice"); // Write a creator. let mut creator_meta = HashMap::new(); creator_meta.insert("name".to_string(), "bob".to_string()); db.write_creator(EntityId::new(2000), &creator_meta) .unwrap(); // Read it back. let meta = db .get_creator_metadata(EntityId::new(2000)) .unwrap() .unwrap(); assert_eq!(meta.get("name").unwrap(), "bob"); // Non-existent returns None. assert!(db.get_user_metadata(EntityId::new(9999)).unwrap().is_none()); assert!( db.get_creator_metadata(EntityId::new(9999)) .unwrap() .is_none() ); } // ── Test 2: Relationship Graph ───────────────────────────────────────────── #[test] fn relationship_graph_crud() { let db = test_db(); let user = EntityId::new(1); let creator = EntityId::new(100); let ts = Timestamp::now(); // Write a Follows relationship. db.write_relationship(user, RelationshipType::Follows, creator, 1.0, ts) .unwrap(); // List relationships. let rels = db .list_relationships(user, RelationshipType::Follows) .unwrap(); assert_eq!(rels.len(), 1); assert_eq!(rels[0].0, creator); assert!((rels[0].1 - 1.0).abs() < f64::EPSILON); // Delete relationship. db.delete_relationship(user, RelationshipType::Follows, creator) .unwrap(); let rels = db .list_relationships(user, RelationshipType::Follows) .unwrap(); assert!(rels.is_empty()); } // ── Test 3: User State (seen, blocked, saved, liked) ─────────────────────── #[test] fn user_state_tracking() { let db = test_db(); let us = db.user_state(); // Seen. us.mark_seen(1, 10); assert!(us.is_seen(1, 10)); assert!(!us.is_seen(1, 20)); // Blocked creator via relationship. db.write_relationship( EntityId::new(1), RelationshipType::Blocks, EntityId::new(200), 1.0, Timestamp::now(), ) .unwrap(); let blocked = us.blocked_creators(1); assert!(blocked.contains(&200)); // Hidden item via relationship. db.write_relationship( EntityId::new(1), RelationshipType::Hide, EntityId::new(50), 1.0, Timestamp::now(), ) .unwrap(); let hidden = us.hidden_items(1); assert!(hidden.contains(50)); // Saved + liked. us.add_save(1, 30); us.add_like(1, 40); assert!(us.is_saved(1, 30)); assert!(us.is_liked(1, 40)); // Completion. us.record_completion(1, 10, 0.5); assert!(us.is_in_progress(1, 10, 0.8)); } // ── Test 4: Signal Dispatch (side effects) ───────────────────────────────── #[test] fn signal_dispatch_records_side_effects() { let db = test_db(); let user_id = 42u64; let entity_id = EntityId::new(100); let creator_id = 200u64; let ts = Timestamp::now(); // A "view" signal with user context: should mark seen + update interaction. db.signal_with_context("view", entity_id, 1.0, ts, Some(user_id), Some(creator_id)) .unwrap(); // Check seen tracking. assert!(db.user_state().is_seen(user_id, 100)); // Check interaction ledger. let score = db .interaction_ledger() .score(user_id, creator_id, ts.as_nanos()); assert!(score > 0.0); // A "skip" signal: should record hard negative. let item2 = EntityId::new(200); db.signal_with_context("skip", item2, 1.0, ts, Some(user_id), Some(creator_id)) .unwrap(); assert!(db.hard_negatives().is_negative(user_id, 200)); } // ── Test 5: Preference Vectors ───────────────────────────────────────────── #[test] fn preference_vector_operations() { let db = test_db(); let pv = db.preference_vectors(); // Initially empty. assert!(pv.get(1).is_none()); // Set initial preference. assert!(pv.set(1, vec![1.0; 128])); let v = pv.get(1).unwrap(); assert_eq!(v.len(), 128); // Cosine similarity with self should be ~1.0. let sim = pv.cosine_similarity(1, &vec![1.0; 128]).unwrap(); assert!((sim - 1.0).abs() < 1e-5); // Wrong dimension rejected. assert!(!pv.set(2, vec![1.0; 64])); } // ── Test 6: Hard-Negative Exclusion in Query ─────────────────────────────── #[test] fn hard_negatives_excluded_from_personalized_query() { let db = test_db(); let user_id = 1u64; let ts = Timestamp::now(); // Write 10 items. for i in 1..=10u64 { let mut meta = HashMap::new(); meta.insert("category".to_string(), "jazz".to_string()); meta.insert("format".to_string(), "video".to_string()); meta.insert("creator_id".to_string(), "100".to_string()); db.write_item_with_metadata(EntityId::new(i), &meta) .unwrap(); } // Record views on all items. for i in 1..=10u64 { db.signal("view", EntityId::new(i), 1.0, ts).unwrap(); } // Skip items 3 and 7 (hard negatives). db.signal_with_context("skip", EntityId::new(3), 1.0, ts, Some(user_id), None) .unwrap(); db.signal_with_context("skip", EntityId::new(7), 1.0, ts, Some(user_id), None) .unwrap(); // Query with FOR USER. let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("new")) .for_user(user_id) .limit(20) .build() .unwrap(); let results = db.retrieve(&query).unwrap(); // Items 3 and 7 should be excluded. let ids: Vec = results.items.iter().map(|r| r.entity_id.as_u64()).collect(); assert!(!ids.contains(&3), "hard-negative item 3 should be excluded"); assert!(!ids.contains(&7), "hard-negative item 7 should be excluded"); } // ── Test 7: Seen Items Excluded from Personalized Query ──────────────────── #[test] fn seen_items_excluded_from_personalized_query() { let db = test_db(); let user_id = 1u64; let ts = Timestamp::now(); // Write 5 items. for i in 1..=5u64 { let mut meta = HashMap::new(); meta.insert("category".to_string(), "blues".to_string()); meta.insert("format".to_string(), "audio".to_string()); db.write_item_with_metadata(EntityId::new(i), &meta) .unwrap(); db.signal("view", EntityId::new(i), 1.0, ts).unwrap(); } // Mark items 1, 2, 3 as seen. db.user_state().mark_seen(user_id, 1); db.user_state().mark_seen(user_id, 2); db.user_state().mark_seen(user_id, 3); // Query with FOR USER. let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("new")) .for_user(user_id) .limit(20) .build() .unwrap(); let results = db.retrieve(&query).unwrap(); // Only items 4 and 5 should remain. let ids: Vec = results.items.iter().map(|r| r.entity_id.as_u64()).collect(); assert_eq!(ids.len(), 2); assert!(ids.contains(&4)); assert!(ids.contains(&5)); } // ── Test 8: Blocked Creator Items Excluded ───────────────────────────────── #[test] fn blocked_creator_items_excluded() { let db = test_db(); let user_id = 1u64; let ts = Timestamp::now(); // Write items from two creators. for i in 1..=5u64 { let mut meta = HashMap::new(); meta.insert("category".to_string(), "rock".to_string()); meta.insert("format".to_string(), "video".to_string()); // Items 1-3 from creator 100, items 4-5 from creator 200. let cid = if i <= 3 { "100" } else { "200" }; meta.insert("creator_id".to_string(), cid.to_string()); db.write_item_with_metadata(EntityId::new(i), &meta) .unwrap(); db.signal("view", EntityId::new(i), 1.0, ts).unwrap(); } // Block creator 100. db.write_relationship( EntityId::new(user_id), RelationshipType::Blocks, EntityId::new(100), 1.0, ts, ) .unwrap(); // Query with FOR USER. let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("new")) .for_user(user_id) .limit(20) .build() .unwrap(); let results = db.retrieve(&query).unwrap(); // Items 1-3 (from blocked creator 100) should be excluded. let ids: Vec = results.items.iter().map(|r| r.entity_id.as_u64()).collect(); assert!( !ids.contains(&1) && !ids.contains(&2) && !ids.contains(&3), "items from blocked creator 100 should be excluded, got: {ids:?}" ); assert_eq!(ids.len(), 2, "only items from creator 200 should remain"); } // ── Test 9: Personalized Profiles Exist and Work ─────────────────────────── #[test] fn personalized_profiles_available() { let db = test_db(); let ts = Timestamp::now(); // Write some items. for i in 1..=10u64 { let mut meta = HashMap::new(); meta.insert("category".to_string(), "jazz".to_string()); meta.insert("format".to_string(), "video".to_string()); meta.insert("creator_id".to_string(), (i % 3).to_string()); db.write_item_with_metadata(EntityId::new(i), &meta) .unwrap(); db.signal("view", EntityId::new(i), 1.0, ts).unwrap(); } // Test each personalized profile. for profile_name in &["for_you", "following", "related", "notification"] { let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new(*profile_name)) .for_user(1) .limit(5) .build() .unwrap(); let results = db.retrieve(&query); assert!( results.is_ok(), "profile '{profile_name}' should execute without error: {:?}", results.err() ); } } // ── Test 10: Interaction Ledger Decay ────────────────────────────────────── #[test] fn interaction_weight_decays() { let db = test_db(); let il = db.interaction_ledger(); let base_ns = 1_000_000_000_000u64; // Record interaction. il.record(1, 100, 10.0, base_ns); // Score at write time. let score_now = il.score(1, 100, base_ns); assert!((score_now - 10.0).abs() < 1e-6); // Score after 7 days (one half-life with default 7-day half-life). let one_week_later = base_ns + 7 * 24 * 3600 * 1_000_000_000; let score_later = il.score(1, 100, one_week_later); // Should be approximately half. assert!( (score_later - 5.0).abs() < 0.5, "score after one half-life should be ~5.0, got {score_later}" ); } // ── Test 11: Full Feedback Loop ──────────────────────────────────────────── #[test] fn full_feedback_loop() { let db = test_db(); let user_id = 42u64; let ts = Timestamp::now(); // 1. Create a user. let mut user_meta = HashMap::new(); user_meta.insert("name".to_string(), "charlie".to_string()); db.write_user(EntityId::new(user_id), &user_meta).unwrap(); // 2. Create creators. for cid in [100, 200, 300] { let mut meta = HashMap::new(); meta.insert("name".to_string(), format!("creator_{cid}")); db.write_creator(EntityId::new(cid), &meta).unwrap(); } // 3. Write items from each creator. for i in 1..=9u64 { let cid = match i { 1..=3 => "100", 4..=6 => "200", _ => "300", }; let mut meta = HashMap::new(); meta.insert("category".to_string(), "jazz".to_string()); meta.insert("format".to_string(), "video".to_string()); meta.insert("creator_id".to_string(), cid.to_string()); db.write_item_with_metadata(EntityId::new(i), &meta) .unwrap(); } // 4. User follows creator 100. db.write_relationship( EntityId::new(user_id), RelationshipType::Follows, EntityId::new(100), 1.0, ts, ) .unwrap(); // 5. User views items from creator 100 (high interaction weight). // Use weight=3.0 to ensure creator 100 has the highest interaction score. for i in 1..=3u64 { db.signal_with_context("view", EntityId::new(i), 3.0, ts, Some(user_id), Some(100)) .unwrap(); } // 6. Record view signals on items 4-9 (no user context — these should NOT // be marked as "seen" so they remain eligible for personalized queries). for i in 4..=9u64 { db.signal("view", EntityId::new(i), 1.0, ts).unwrap(); } // Record interaction weights for creators 200 and 300 directly so the // interaction ledger is populated for the assertion in step 10. for i in 4..=6u64 { db.interaction_ledger() .record(user_id, 200, 1.0, ts.as_nanos()); let _ = i; // suppress unused-variable warning } for i in 7..=9u64 { db.interaction_ledger() .record(user_id, 300, 1.0, ts.as_nanos()); let _ = i; } // 7. User blocks creator 300. db.write_relationship( EntityId::new(user_id), RelationshipType::Blocks, EntityId::new(300), 1.0, ts, ) .unwrap(); // 8. User skips item 5 (hard negative). Pass `None` for creator_id // because a skip should not boost creator interaction weight. db.signal_with_context("skip", EntityId::new(5), 1.0, ts, Some(user_id), None) .unwrap(); // 9. Query: FOR USER with new profile. // Expected: items 7,8,9 excluded (blocked creator 300), // item 5 excluded (hard negative), items 1-3 excluded (seen), // only items 4 and 6 remain. let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("new")) .for_user(user_id) .limit(20) .build() .unwrap(); let results = db.retrieve(&query).unwrap(); let ids: Vec = results.items.iter().map(|r| r.entity_id.as_u64()).collect(); // Blocked creator 300's items (7,8,9) should be excluded. assert!( !ids.contains(&7) && !ids.contains(&8) && !ids.contains(&9), "blocked creator items should be excluded, got: {ids:?}" ); // Hard negative (item 5) should be excluded. assert!( !ids.contains(&5), "hard-negative item 5 should be excluded, got: {ids:?}" ); // Seen items (1,2,3) should be excluded. assert!( !ids.contains(&1) && !ids.contains(&2) && !ids.contains(&3), "seen items should be excluded, got: {ids:?}" ); // Only items 4 and 6 should remain. assert_eq!( ids.len(), 2, "expected exactly 2 items (4 and 6), got: {ids:?}" ); assert!(ids.contains(&4) && ids.contains(&6)); // 10. Verify interaction ledger has creator 100 as top interaction. let top = db .interaction_ledger() .top_creators(user_id, 3, ts.as_nanos()); assert!( !top.is_empty(), "interaction ledger should have entries for user" ); // Creator 100 should have highest interaction weight (3 views). assert_eq!( top[0].0, 100, "creator 100 should be top interaction, got: {top:?}" ); }