tidaldb/tidal/tests/p1_feedback_loop.rs

307 lines
9.4 KiB
Rust

#![allow(clippy::unwrap_used)]
//! Integration tests for `p1-feedback-loop-ux` feature.
//!
//! Validates the complete feedback lifecycle: submit_feedback, feedback_state,
//! immediate reflection in queries, skip escalation, and preference vector
//! negative updates.
use std::collections::HashMap;
use std::time::Duration;
use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window};
use tidaldb::{FeedbackAction, FeedbackState, TidalDb, TidalDbBuilder};
// ── Helpers ────────────────────────────────────────────────────────────────
fn feedback_schema() -> tidaldb::schema::Schema {
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::AllTime])
.velocity(true)
.add();
let _ = builder
.signal(
"like",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.add();
let _ = builder
.signal("hide", EntityKind::Item, DecaySpec::Permanent)
.add();
let _ = builder
.signal(
"dislike",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(30 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.add();
let _ = builder
.signal("block", EntityKind::Item, DecaySpec::Permanent)
.add();
let _ = builder
.signal(
"skip",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(24 * 3600),
},
)
.windows(&[Window::AllTime])
.add();
builder.embedding_slot("content", EntityKind::Item, 8);
builder.build().unwrap()
}
fn open_db() -> TidalDb {
TidalDbBuilder::new()
.ephemeral()
.with_schema(feedback_schema())
.open()
.unwrap()
}
fn write_items(db: &TidalDb, ids: &[u64]) {
for &id in ids {
let mut meta = HashMap::new();
meta.insert("title".to_string(), format!("Item {id}"));
db.write_item(EntityId::new(id), &meta).unwrap();
}
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[test]
fn like_and_unlike_roundtrip() {
let db = open_db();
write_items(&db, &[1]);
let ts = Timestamp::now();
db.submit_feedback(100, EntityId::new(1), FeedbackAction::Like, ts)
.unwrap();
let state = db.feedback_state(100, EntityId::new(1), None);
assert!(state.is_liked, "liked should be true after Like");
assert!(!state.is_hidden);
assert!(!state.is_disliked);
db.submit_feedback(100, EntityId::new(1), FeedbackAction::Unlike, ts)
.unwrap();
let state = db.feedback_state(100, EntityId::new(1), None);
assert!(!state.is_liked, "liked should be false after Unlike");
}
#[test]
fn hide_and_unhide_roundtrip() {
let db = open_db();
write_items(&db, &[1]);
let ts = Timestamp::now();
db.submit_feedback(100, EntityId::new(1), FeedbackAction::Hide, ts)
.unwrap();
let state = db.feedback_state(100, EntityId::new(1), None);
assert!(state.is_hidden, "hidden should be true after Hide");
assert!(
!state.is_disliked,
"disliked should be false for Hide (not Dislike)"
);
db.submit_feedback(100, EntityId::new(1), FeedbackAction::Unhide, ts)
.unwrap();
let state = db.feedback_state(100, EntityId::new(1), None);
assert!(!state.is_hidden, "hidden should be false after Unhide");
}
#[test]
fn dislike_and_undo_dislike_roundtrip() {
let db = open_db();
write_items(&db, &[1]);
let ts = Timestamp::now();
db.submit_feedback(100, EntityId::new(1), FeedbackAction::Dislike, ts)
.unwrap();
let state = db.feedback_state(100, EntityId::new(1), None);
assert!(state.is_disliked, "disliked should be true after Dislike");
assert!(
!state.is_hidden,
"hidden should be false for Dislike (not Hide)"
);
db.submit_feedback(100, EntityId::new(1), FeedbackAction::UndoDislike, ts)
.unwrap();
let state = db.feedback_state(100, EntityId::new(1), None);
assert!(
!state.is_disliked,
"disliked should be false after UndoDislike"
);
}
#[test]
fn mute_and_unmute_creator() {
let db = open_db();
write_items(&db, &[1]);
let ts = Timestamp::now();
db.submit_feedback(
100,
EntityId::new(1),
FeedbackAction::MuteCreator { creator_id: 50 },
ts,
)
.unwrap();
let state = db.feedback_state(100, EntityId::new(1), Some(50));
assert!(
state.is_creator_muted,
"creator should be muted after MuteCreator"
);
db.submit_feedback(
100,
EntityId::new(1),
FeedbackAction::UnmuteCreator { creator_id: 50 },
ts,
)
.unwrap();
let state = db.feedback_state(100, EntityId::new(1), Some(50));
assert!(
!state.is_creator_muted,
"creator should not be muted after UnmuteCreator"
);
}
#[test]
fn save_and_unsave_roundtrip() {
let db = open_db();
write_items(&db, &[1]);
let ts = Timestamp::now();
db.submit_feedback(100, EntityId::new(1), FeedbackAction::Save, ts)
.unwrap();
let state = db.feedback_state(100, EntityId::new(1), None);
assert!(state.is_saved, "saved should be true after Save");
db.submit_feedback(100, EntityId::new(1), FeedbackAction::Unsave, ts)
.unwrap();
let state = db.feedback_state(100, EntityId::new(1), None);
assert!(!state.is_saved, "saved should be false after Unsave");
}
#[test]
fn skip_escalation_at_threshold() {
let db = open_db();
write_items(&db, &[1]);
let ts = Timestamp::now();
// First 2 skips: no hard negative.
for _ in 0..2 {
db.submit_feedback(100, EntityId::new(1), FeedbackAction::NotInterested, ts)
.unwrap();
}
let state = db.feedback_state(100, EntityId::new(1), None);
assert!(
!state.is_hidden && !state.is_disliked,
"2 skips should not escalate"
);
// 3rd skip: escalates to hard negative.
db.submit_feedback(100, EntityId::new(1), FeedbackAction::NotInterested, ts)
.unwrap();
// After escalation, the item is in hard_negatives but not in hidden_items,
// so feedback_state shows is_disliked = true (hard_neg && !hidden).
let state = db.feedback_state(100, EntityId::new(1), None);
assert!(
state.is_disliked,
"3 skips should escalate to hard negative (shows as disliked)"
);
}
#[test]
fn feedback_state_default_for_unknown_pair() {
let db = open_db();
let state = db.feedback_state(999, EntityId::new(999), None);
assert_eq!(
state,
FeedbackState::default(),
"unknown user-entity pair should return all-false state"
);
}
#[test]
fn multiple_users_independent_feedback() {
let db = open_db();
write_items(&db, &[1]);
let ts = Timestamp::now();
// User 100 likes item 1.
db.submit_feedback(100, EntityId::new(1), FeedbackAction::Like, ts)
.unwrap();
// User 200 hides item 1.
db.submit_feedback(200, EntityId::new(1), FeedbackAction::Hide, ts)
.unwrap();
let state_100 = db.feedback_state(100, EntityId::new(1), None);
assert!(state_100.is_liked);
assert!(!state_100.is_hidden);
let state_200 = db.feedback_state(200, EntityId::new(1), None);
assert!(!state_200.is_liked);
assert!(state_200.is_hidden);
}
#[test]
fn negative_preference_update_on_dislike() {
let db = open_db();
let eid = EntityId::new(1);
// Write an item with an embedding so dislike can trigger preference update.
let mut meta = HashMap::new();
meta.insert("title".to_string(), "Test Item".to_string());
db.write_item(eid, &meta).unwrap();
db.write_item_embedding(eid, &[0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5])
.unwrap();
let ts = Timestamp::now();
db.submit_feedback(100, eid, FeedbackAction::Dislike, ts)
.unwrap();
// Dislike should have triggered try_negative_preference_update.
// The preference vector should now be nudged AWAY from the item embedding.
// We verify the state is consistent; the exact vector values depend on
// internal DAMPING math, but the state should be set up.
let state = db.feedback_state(100, eid, None);
assert!(state.is_disliked);
}
#[test]
fn combined_actions_feedback_state() {
let db = open_db();
write_items(&db, &[1]);
let ts = Timestamp::now();
// Like + Save on the same item.
db.submit_feedback(100, EntityId::new(1), FeedbackAction::Like, ts)
.unwrap();
db.submit_feedback(100, EntityId::new(1), FeedbackAction::Save, ts)
.unwrap();
let state = db.feedback_state(100, EntityId::new(1), None);
assert!(state.is_liked);
assert!(state.is_saved);
assert!(!state.is_hidden);
assert!(!state.is_disliked);
assert!(!state.is_creator_muted);
}