446 lines
14 KiB
Rust
446 lines
14 KiB
Rust
//! Milestone 6 Phase 4 Integration Tests: Collections, Saved Searches, Cross-Session Preference.
|
|
//!
|
|
//! Exercises the M6p4 feature set end-to-end through `TidalDb`:
|
|
//!
|
|
//! 1. Collection CRUD (create, add, list, remove).
|
|
//! 2. `FilterExpr::InCollection` constrains retrieve candidates.
|
|
//! 3. `InProgress` user-state filter evaluation.
|
|
//! 4. Saved search CRUD (save, list, retrieve, delete).
|
|
//! 5. Collection persistence across restart.
|
|
//! 6. Saved search persistence across restart.
|
|
//! 7. Cross-session preference aggregation.
|
|
//! 8. `in_collection` filter performance.
|
|
|
|
#![allow(clippy::unwrap_used, clippy::cast_precision_loss)]
|
|
|
|
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
|
|
use tidaldb::TidalDb;
|
|
use tidaldb::entities::Visibility;
|
|
use tidaldb::query::retrieve::Retrieve;
|
|
use tidaldb::schema::{
|
|
AgentPolicy, DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window,
|
|
};
|
|
use tidaldb::storage::indexes::filter::FilterExpr;
|
|
|
|
// ── Schema ──────────────────────────────────────────────────────────────────
|
|
|
|
fn m6p4_schema() -> tidaldb::schema::Schema {
|
|
let mut builder = SchemaBuilder::new();
|
|
|
|
for &(name, half_life_days) in &[
|
|
("view", 7),
|
|
("like", 14),
|
|
("share", 7),
|
|
("skip", 1),
|
|
("completion", 30),
|
|
] {
|
|
let _ = builder
|
|
.signal(
|
|
name,
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(half_life_days * 24 * 3600),
|
|
},
|
|
)
|
|
.windows(&[
|
|
Window::OneHour,
|
|
Window::TwentyFourHours,
|
|
Window::SevenDays,
|
|
Window::AllTime,
|
|
])
|
|
.velocity(true)
|
|
.add();
|
|
}
|
|
|
|
builder.build().expect("m6p4 schema must be valid")
|
|
}
|
|
|
|
fn open_ephemeral() -> TidalDb {
|
|
TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(m6p4_schema())
|
|
.open()
|
|
.expect("ephemeral open")
|
|
}
|
|
|
|
fn write_items(db: &TidalDb, ids: &[u64]) {
|
|
for &id in ids {
|
|
let mut meta = HashMap::new();
|
|
meta.insert("title".to_string(), format!("Item {id}"));
|
|
meta.insert("category".to_string(), "jazz".to_string());
|
|
meta.insert("format".to_string(), "audio".to_string());
|
|
db.write_item_with_metadata(EntityId::new(id), &meta)
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
// ── Test 1: Collection create, add, list ────────────────────────────────────
|
|
|
|
#[test]
|
|
fn collection_create_add_list() {
|
|
let db = open_ephemeral();
|
|
let owner = EntityId::new(1);
|
|
|
|
let cid = db
|
|
.create_collection(owner, "favorites", Visibility::Private)
|
|
.unwrap();
|
|
assert_eq!(cid.as_u64(), 1);
|
|
|
|
db.add_to_collection(cid, EntityId::new(100)).unwrap();
|
|
db.add_to_collection(cid, EntityId::new(200)).unwrap();
|
|
// Idempotent: adding same item twice is fine.
|
|
db.add_to_collection(cid, EntityId::new(100)).unwrap();
|
|
|
|
let collections = db.list_collections(owner).unwrap();
|
|
assert_eq!(collections.len(), 1);
|
|
assert_eq!(collections[0].name, "favorites");
|
|
assert_eq!(collections[0].item_count, 2); // 100 and 200
|
|
assert_eq!(collections[0].visibility, Visibility::Private);
|
|
}
|
|
|
|
// ── Test 2: Remove from collection ──────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn collection_remove_item() {
|
|
let db = open_ephemeral();
|
|
let owner = EntityId::new(1);
|
|
let cid = db
|
|
.create_collection(owner, "watchlist", Visibility::Public)
|
|
.unwrap();
|
|
|
|
db.add_to_collection(cid, EntityId::new(10)).unwrap();
|
|
db.add_to_collection(cid, EntityId::new(20)).unwrap();
|
|
db.add_to_collection(cid, EntityId::new(30)).unwrap();
|
|
|
|
db.remove_from_collection(cid, EntityId::new(20)).unwrap();
|
|
// Removing again is a no-op.
|
|
db.remove_from_collection(cid, EntityId::new(20)).unwrap();
|
|
|
|
let collections = db.list_collections(owner).unwrap();
|
|
assert_eq!(collections[0].item_count, 2); // 10 and 30
|
|
}
|
|
|
|
// ── Test 3: FilterExpr::InCollection constrains retrieve candidates ─────────
|
|
|
|
#[test]
|
|
fn retrieve_with_in_collection_filter() {
|
|
let db = open_ephemeral();
|
|
let owner = EntityId::new(1);
|
|
|
|
// Write 10 items.
|
|
write_items(&db, &(1..=10).collect::<Vec<_>>());
|
|
|
|
// Create a collection with items 3, 7, 9.
|
|
let cid = db
|
|
.create_collection(owner, "picks", Visibility::Private)
|
|
.unwrap();
|
|
db.add_to_collection(cid, EntityId::new(3)).unwrap();
|
|
db.add_to_collection(cid, EntityId::new(7)).unwrap();
|
|
db.add_to_collection(cid, EntityId::new(9)).unwrap();
|
|
|
|
// Retrieve with InCollection filter.
|
|
let query = Retrieve::builder()
|
|
.profile("new")
|
|
.filter(FilterExpr::in_collection(cid))
|
|
.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();
|
|
assert_eq!(ids.len(), 3);
|
|
assert!(ids.contains(&3));
|
|
assert!(ids.contains(&7));
|
|
assert!(ids.contains(&9));
|
|
}
|
|
|
|
// ── Test 4: InProgress user-state filter ────────────────────────────────────
|
|
|
|
#[test]
|
|
fn retrieve_in_progress_filter() {
|
|
let db = open_ephemeral();
|
|
let user_id = 42u64;
|
|
|
|
// Write 5 items.
|
|
write_items(&db, &[1, 2, 3, 4, 5]);
|
|
|
|
// Record completion progress via user-state index.
|
|
// Item 2: 30% (in progress), Item 4: 95% (complete), Item 5: 50% (in progress).
|
|
db.user_state().record_completion(user_id, 2, 0.3);
|
|
db.user_state().record_completion(user_id, 4, 0.95);
|
|
db.user_state().record_completion(user_id, 5, 0.5);
|
|
|
|
// Retrieve with InProgress filter (threshold=0.9).
|
|
let query = Retrieve::builder()
|
|
.profile("new")
|
|
.for_user(user_id)
|
|
.filter(FilterExpr::InProgress {
|
|
user_id,
|
|
threshold: 0.9,
|
|
})
|
|
.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();
|
|
// Items 2 (0.3) and 5 (0.5) are in progress (0 < v < 0.9).
|
|
// Item 4 (0.95) is complete (>= 0.9).
|
|
assert_eq!(ids.len(), 2, "expected 2 in-progress items, got {ids:?}");
|
|
assert!(ids.contains(&2));
|
|
assert!(ids.contains(&5));
|
|
}
|
|
|
|
// ── Test 5: Saved search CRUD ───────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn saved_search_crud() {
|
|
let db = open_ephemeral();
|
|
let user = EntityId::new(1);
|
|
|
|
db.save_search(user, "jazz-tunes", "jazz music", None)
|
|
.unwrap();
|
|
db.save_search(user, "rust-db", "rust database", None)
|
|
.unwrap();
|
|
|
|
let searches = db.list_saved_searches(user).unwrap();
|
|
assert_eq!(searches.len(), 2);
|
|
|
|
let names: Vec<&str> = searches.iter().map(|s| s.name.as_str()).collect();
|
|
assert!(names.contains(&"jazz-tunes"));
|
|
assert!(names.contains(&"rust-db"));
|
|
}
|
|
|
|
// ── Test 6: Delete saved search ─────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn saved_search_delete() {
|
|
let db = open_ephemeral();
|
|
let user = EntityId::new(1);
|
|
|
|
db.save_search(user, "temp-search", "temporary query", None)
|
|
.unwrap();
|
|
|
|
let before = db.list_saved_searches(user).unwrap();
|
|
assert_eq!(before.len(), 1);
|
|
|
|
db.delete_saved_search(user, "temp-search").unwrap();
|
|
|
|
let after = db.list_saved_searches(user).unwrap();
|
|
assert!(after.is_empty());
|
|
}
|
|
|
|
// ── Test 7: Collections persist across restart ──────────────────────────────
|
|
|
|
#[test]
|
|
fn collections_persist_across_restart() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let schema = m6p4_schema();
|
|
|
|
// First open: create collection and add items.
|
|
{
|
|
let db = TidalDb::builder()
|
|
.with_data_dir(dir.path())
|
|
.with_schema(schema.clone())
|
|
.open()
|
|
.unwrap();
|
|
|
|
let cid = db
|
|
.create_collection(EntityId::new(1), "watchlist", Visibility::Private)
|
|
.unwrap();
|
|
db.add_to_collection(cid, EntityId::new(100)).unwrap();
|
|
db.add_to_collection(cid, EntityId::new(200)).unwrap();
|
|
|
|
db.close().unwrap();
|
|
}
|
|
|
|
// Second open: verify collection survives.
|
|
{
|
|
let db = TidalDb::builder()
|
|
.with_data_dir(dir.path())
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
let collections = db.list_collections(EntityId::new(1)).unwrap();
|
|
assert_eq!(collections.len(), 1, "collection should survive restart");
|
|
assert_eq!(collections[0].name, "watchlist");
|
|
assert_eq!(collections[0].item_count, 2);
|
|
|
|
db.close().unwrap();
|
|
}
|
|
}
|
|
|
|
// ── Test 8: Saved searches persist across restart ───────────────────────────
|
|
|
|
#[test]
|
|
fn saved_searches_persist_across_restart() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let schema = m6p4_schema();
|
|
|
|
{
|
|
let db = TidalDb::builder()
|
|
.with_data_dir(dir.path())
|
|
.with_schema(schema.clone())
|
|
.open()
|
|
.unwrap();
|
|
|
|
db.save_search(EntityId::new(1), "my-query", "rust database", None)
|
|
.unwrap();
|
|
|
|
db.close().unwrap();
|
|
}
|
|
|
|
{
|
|
let db = TidalDb::builder()
|
|
.with_data_dir(dir.path())
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
let searches = db.list_saved_searches(EntityId::new(1)).unwrap();
|
|
assert_eq!(searches.len(), 1, "saved search should survive restart");
|
|
assert_eq!(searches[0].name, "my-query");
|
|
assert_eq!(searches[0].query_text, "rust database");
|
|
|
|
db.close().unwrap();
|
|
}
|
|
}
|
|
|
|
// ── Test 9: Cross-session preference aggregation ────────────────────────────
|
|
|
|
#[test]
|
|
fn cross_session_preference_aggregation() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
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::AllTime])
|
|
.velocity(false)
|
|
.add();
|
|
// Declare the 4-dimensional embedding slot so PreferenceVectors initializes
|
|
// with dim=4 (matching the embeddings written in this test).
|
|
builder.embedding_slot("content", EntityKind::Item, 4);
|
|
// Add a session policy so we can open sessions.
|
|
builder.session_policy(
|
|
"default",
|
|
AgentPolicy {
|
|
allowed_signals: vec!["view".to_string()],
|
|
denied_signals: vec![],
|
|
max_session_duration: Duration::from_secs(3600),
|
|
max_signals_per_session: 100,
|
|
},
|
|
);
|
|
let schema = builder.build().unwrap();
|
|
|
|
let db = TidalDb::builder()
|
|
.with_data_dir(dir.path())
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
// Write an item with an embedding.
|
|
let item_id = EntityId::new(42);
|
|
let mut meta = HashMap::new();
|
|
meta.insert("title".to_string(), "Test Item".to_string());
|
|
meta.insert("category".to_string(), "jazz".to_string());
|
|
meta.insert("format".to_string(), "audio".to_string());
|
|
db.write_item_with_metadata(item_id, &meta).unwrap();
|
|
|
|
// Write an embedding for the item.
|
|
let embedding = vec![1.0f32, 0.0, 0.0, 0.0];
|
|
db.write_item_embedding(item_id, &embedding).unwrap();
|
|
|
|
// Verify no preference exists for user yet.
|
|
let user_id = 1u64;
|
|
assert!(
|
|
db.preference_vectors().get(user_id).is_none(),
|
|
"no preference before session"
|
|
);
|
|
|
|
// Open a session, signal the item, close session.
|
|
let handle = db
|
|
.start_session(user_id, "test-agent", "default", HashMap::new())
|
|
.unwrap();
|
|
|
|
db.session_signal(&handle, "view", item_id, 1.0, Timestamp::now(), None)
|
|
.unwrap();
|
|
|
|
db.close_session(handle).unwrap();
|
|
|
|
// After close_session, the preference vector should be non-empty
|
|
// (blended from the item's embedding with damping=0.1).
|
|
let pref = db.preference_vectors().get(user_id);
|
|
assert!(
|
|
pref.is_some(),
|
|
"preference should exist after cross-session aggregation"
|
|
);
|
|
let pref = pref.unwrap();
|
|
assert_eq!(
|
|
pref.len(),
|
|
4,
|
|
"preference should have same dim as embedding"
|
|
);
|
|
// The preference should be approximately [1, 0, 0, 0] (normalized).
|
|
assert!(
|
|
pref[0] > 0.5,
|
|
"first component should be dominant, got {:.4}",
|
|
pref[0]
|
|
);
|
|
|
|
db.close().unwrap();
|
|
}
|
|
|
|
// ── Test 10: InCollection filter performance ────────────────────────────────
|
|
|
|
#[test]
|
|
fn in_collection_filter_performance() {
|
|
let db = open_ephemeral();
|
|
let owner = EntityId::new(1);
|
|
|
|
// Write 1000 items.
|
|
for id in 1..=1000u64 {
|
|
let mut meta = HashMap::new();
|
|
meta.insert("title".to_string(), format!("Item {id}"));
|
|
meta.insert("category".to_string(), "jazz".to_string());
|
|
meta.insert("format".to_string(), "audio".to_string());
|
|
db.write_item_with_metadata(EntityId::new(id), &meta)
|
|
.unwrap();
|
|
}
|
|
|
|
// Create a collection with 100 items.
|
|
let cid = db
|
|
.create_collection(owner, "bench-collection", Visibility::Private)
|
|
.unwrap();
|
|
for id in 1..=100u64 {
|
|
db.add_to_collection(cid, EntityId::new(id)).unwrap();
|
|
}
|
|
|
|
// Time the retrieve with InCollection filter.
|
|
let start = std::time::Instant::now();
|
|
let query = Retrieve::builder()
|
|
.profile("new")
|
|
.filter(FilterExpr::in_collection(cid))
|
|
.limit(100)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
let elapsed = start.elapsed();
|
|
|
|
assert_eq!(results.total_candidates, 100);
|
|
assert!(
|
|
elapsed.as_millis() < 10,
|
|
"in_collection filter took {}ms, expected < 10ms",
|
|
elapsed.as_millis()
|
|
);
|
|
}
|