Resolves the 142 findings from tidal/docs/reviews/CODE_REVIEW_m0-m10.md across the engine, server, net, and CLI surfaces: - WAL/session-journal durability, checkpoint format, and crash-recovery hardening - Replication shipper/receiver, tenant isolation, and migration paths - Cluster scatter-gather, router, standalone server + health/offload endpoints - tidalctl refactored into command modules with JSON output and WAL-state tooling - Cohort, governance, signal-ledger, and vector-registry correctness fixes - Expanded UAT/integration/durability test coverage across all milestones
640 lines
21 KiB
Rust
640 lines
21 KiB
Rust
#![allow(clippy::too_many_lines, 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, time::Duration};
|
|
|
|
use tidaldb::{
|
|
TidalDb,
|
|
entities::RelationshipType,
|
|
query::retrieve::{ProfileRef, RetrieveBuilder},
|
|
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:?}"
|
|
);
|
|
}
|
|
|
|
// ── Preference-vector cosine boost (end-to-end through retrieve) ─────────────
|
|
|
|
/// Proves the per-user preference vector actually steers ranking: an item whose
|
|
/// content embedding aligns with the user's preference vector ranks above an
|
|
/// item whose embedding opposes it, with identical base signals. This exercises
|
|
/// the full path -- `preference_vectors().set()` -> `retrieve(FOR USER)` ->
|
|
/// Stage-3 `compute_preference_boosts` -> `score_personalized` additive boost.
|
|
#[test]
|
|
fn preference_vector_boost_steers_personalized_retrieve() {
|
|
// Schema with a 4-D content embedding slot so PreferenceVectors uses dim=4.
|
|
let mut builder = SchemaBuilder::new();
|
|
let _ = builder
|
|
.signal(
|
|
"view",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(7 * 24 * 3600),
|
|
},
|
|
)
|
|
.windows(&[Window::OneHour, Window::TwentyFourHours, Window::SevenDays])
|
|
.velocity(true)
|
|
.add();
|
|
builder.embedding_slot("content", EntityKind::Item, 4);
|
|
let schema = builder.build().unwrap();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
let ts = Timestamp::now();
|
|
let user_id = 500u64;
|
|
|
|
// Two items with an identical `view=1.0` signal but orthogonal/opposed
|
|
// embeddings. The `most_viewed` profile scores purely on the view count, so
|
|
// both items tie on base score and the preference boost is the *sole*
|
|
// differentiator -- isolating the wiring under test.
|
|
let aligned = EntityId::new(10);
|
|
let opposed = EntityId::new(11);
|
|
for (id, title) in [(aligned, "aligned"), (opposed, "opposed")] {
|
|
let mut meta = HashMap::new();
|
|
meta.insert("title".to_string(), title.to_string());
|
|
db.write_item_with_metadata(id, &meta).unwrap();
|
|
db.signal("view", id, 1.0, ts).unwrap();
|
|
}
|
|
// Aligned item points along +x; opposed item points along -x.
|
|
db.write_item_embedding(aligned, &[1.0, 0.0, 0.0, 0.0])
|
|
.unwrap();
|
|
db.write_item_embedding(opposed, &[-1.0, 0.0, 0.0, 0.0])
|
|
.unwrap();
|
|
|
|
let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("most_viewed"))
|
|
.for_user(user_id)
|
|
.limit(20)
|
|
.build()
|
|
.unwrap();
|
|
let score_of = |res: &tidaldb::query::retrieve::Results, id: EntityId| -> f64 {
|
|
res.items
|
|
.iter()
|
|
.find(|r| r.entity_id == id)
|
|
.map(|r| r.score)
|
|
.expect("item present in results")
|
|
};
|
|
|
|
// Baseline: equal view counts and no preference vector -> the two items tie
|
|
// on score (min-max normalize maps an empty range to 1.0 for both).
|
|
let before = db.retrieve(&query).unwrap();
|
|
assert!(
|
|
(score_of(&before, aligned) - score_of(&before, opposed)).abs() < f64::EPSILON,
|
|
"without a preference vector the equally-viewed items must tie on score \
|
|
(aligned={:.4}, opposed={:.4})",
|
|
score_of(&before, aligned),
|
|
score_of(&before, opposed),
|
|
);
|
|
|
|
// Set the user's preference vector to +x: `aligned` matches (cosine +1.0),
|
|
// `opposed` is anti-correlated (cosine -1.0). The preference boost is now the
|
|
// only thing separating them.
|
|
assert!(
|
|
db.preference_vectors()
|
|
.set(user_id, vec![1.0, 0.0, 0.0, 0.0]),
|
|
"preference vector set should succeed at the declared dim"
|
|
);
|
|
|
|
let after = db.retrieve(&query).unwrap();
|
|
assert!(
|
|
score_of(&after, aligned) > score_of(&after, opposed),
|
|
"preference-aligned item must outscore the opposed item once a \
|
|
preference vector is set -- the boost is wired, not a no-op \
|
|
(aligned={:.4}, opposed={:.4})",
|
|
score_of(&after, aligned),
|
|
score_of(&after, opposed),
|
|
);
|
|
assert_eq!(
|
|
after.items[0].entity_id, aligned,
|
|
"preference-aligned item should rank first"
|
|
);
|
|
}
|