tidaldb/tidal/tests/m3_uat.rs
jordan 39ada28c6e feat: complete Milestones 2–4 — RETRIEVE query, vector index, ranking profiles, diversity, entity system, sessions
M2: RETRIEVE query pipeline with 5-stage execution (candidate → filter → score → diversify → limit),
    usearch HNSW vector index, bitmap/range/universe filters, ranking profiles with signal scoring,
    MMR diversity enforcement, and m2_uat integration tests.

M3: Entity system with typed metadata, relationship graph (follows/blocks/interactions),
    creator entities, session tracking, and m3_uat integration tests.

M4: Advanced ranking with builtin functions (freshness, trending, controversy, wilson),
    ranking executor with explain mode, query executor integration, benchmarks for
    query/ranking/vector/filters/diversity, and m4_uat integration tests.

Includes: 9 new blog posts, marketing site updates, updated roadmap, and updated vision doc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:24:48 -07:00

540 lines
17 KiB
Rust

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