# Task 03: M2 UAT Integration Test ## Context **Milestone:** 2 -- Ranked Retrieval **Phase:** m2p5 -- Query Parser and RETRIEVE Executor **Depends On:** Task 01 (Retrieve, Results, QueryError types), Task 02 (RetrieveExecutor, TidalDb::retrieve()) **Blocks:** Milestone 3 (personalized ranking) **Complexity:** M ## Objective Deliver the Milestone 2 User Acceptance Test as a Rust integration test in `tidal/tests/m2_uat.rs`. This test exercises the complete M2 scenario from the roadmap: open a database with a full schema (5 signal types, 6 ranking profiles), write 10K items with metadata and embeddings, write 10K signal events, execute all 6 profile queries verifying ordering and filter correctness, write a signal burst and verify rank change, and re-verify after shutdown and reopen. This is the milestone gate. If it passes, Milestone 2 is done. The test proves that "a single query retrieves, scores, and ranks content using live signals" -- the M2 thesis. ## Requirements - Full M2 UAT scenario from ROADMAP.md implemented as `tidal/tests/m2_uat.rs` - 10K items with metadata (category, format, creator_id) and 64-dim embeddings - 10K signal events spanning 7 days across 5 signal types - All 6 RETRIEVE queries executed and verified: 1. `trending` with `max_per_creator:1` diversity -- 25 results, creator-diverse, score-sorted 2. `hot` with `category:jazz` filter -- only jazz items, score-sorted 3. `new` -- created_at descending 4. `top_week` -- signal-based ordering within 7d window 5. `hidden_gems` -- quality/reach ratio ordering 6. `controversial` -- dual-signal ranking - Signal burst for item #500, re-query trending, verify rank change - Shutdown and reopen, re-verify all queries - All tests use `tempfile::TempDir` for isolation - Tests must pass `cargo test --test m2_uat` - Deterministic test data (fixed timestamps, reproducible event sequences) ## Technical Design ### Module Structure ``` tidal/tests/ m2_uat.rs -- Full M2 UAT integration test ``` ### Test Implementation ```rust // === tidal/tests/m2_uat.rs === use std::collections::HashMap; use std::time::Duration; use tempfile::TempDir; use tidaldb::query::retrieve::Retrieve; use tidaldb::ranking::diversity::DiversityConstraints; use tidaldb::schema::*; use tidaldb::storage::indexes::filter::FilterExpr; use tidaldb::{Config, TidalDB}; // ============================================================ // Test Helpers // ============================================================ /// Build the M2 schema: 5 signal types, 6 ranking profiles, 64-dim embeddings. fn m2_schema() -> Schema { let mut builder = SchemaBuilder::new(); // Embedding slot for items: 64-dim (small for test speed) builder.embedding_slot("default", EntityKind::Item, 64); // Signal types builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), // 7 days }, ) .windows(&[ Window::OneHour, Window::TwentyFourHours, Window::SevenDays, Window::AllTime, ]) .velocity(true) .add(); builder .signal( "like", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(14 * 24 * 3600), // 14 days }, ) .windows(&[ Window::TwentyFourHours, Window::SevenDays, Window::AllTime, ]) .velocity(true) .add(); builder .signal( "skip", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(24 * 3600), // 1 day }, ) .windows(&[Window::OneHour, Window::TwentyFourHours]) .velocity(false) .add(); builder .signal( "share", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(3 * 24 * 3600), // 3 days }, ) .windows(&[ Window::OneHour, Window::TwentyFourHours, Window::SevenDays, ]) .velocity(true) .add(); builder .signal( "completion", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(30 * 24 * 3600), // 30 days }, ) .windows(&[Window::SevenDays, Window::AllTime]) .velocity(false) .add(); // Built-in profiles are auto-registered: trending, hot, new, top_week, // hidden_gems, controversial, most_viewed, most_liked, shuffle, etc. builder.build().unwrap() } /// Categories used for test items. 10 distinct values. const CATEGORIES: &[&str] = &[ "jazz", "rock", "classical", "electronic", "hip_hop", "country", "blues", "folk", "metal", "pop", ]; /// Formats used for test items. 4 distinct values. const FORMATS: &[&str] = &["video", "audio", "article", "short"]; /// Generate deterministic item metadata. /// /// Returns (category, format, creator_id, created_at_offset_nanos). fn item_metadata( item_index: u64, ) -> (String, String, EntityId, u64) { let category = CATEGORIES[(item_index as usize) % CATEGORIES.len()].to_string(); let format = FORMATS[(item_index as usize) % FORMATS.len()].to_string(); // 200 creators, distributed round-robin let creator_id = EntityId::new((item_index % 200) + 1); // Spread creation times across 30 days (newest items have highest index) let thirty_days_nanos = 30u64 * 24 * 3600 * 1_000_000_000; let created_at_offset = (item_index * thirty_days_nanos) / 10_000; (category, format, creator_id, created_at_offset) } /// Generate a deterministic 64-dim embedding for an item. /// /// Uses a simple deterministic formula based on the item index. /// The embeddings are normalized to unit length for cosine similarity. fn generate_embedding(item_index: u64, dimensions: usize) -> Vec { let mut vec: Vec = (0..dimensions) .map(|d| { // Deterministic pseudo-random using item index and dimension let seed = (item_index as f32 * 0.7 + d as f32 * 1.3).sin(); seed }) .collect(); // L2 normalize let norm: f32 = vec.iter().map(|x| x * x).sum::().sqrt(); if norm > 0.0 { for v in &mut vec { *v /= norm; } } vec } /// Generate deterministic signal events spanning a time range. /// /// Distributes events across entities and signal types with a prime /// stride for reproducible but varied patterns. Each entity gets a /// different number of events to create interesting ranking dynamics. fn generate_signal_events( count: usize, entity_count: u64, base_time_nanos: u64, span_nanos: u64, ) -> Vec<(EntityId, &'static str, f64, u64)> { let signal_types = ["view", "like", "skip", "share", "completion"]; let mut events = Vec::with_capacity(count); for i in 0..count { // Entity distribution: power-law-ish (some items get many more events) let entity_raw = ((i as u64) * 7919 + 1) % entity_count; let entity_id = EntityId::new(entity_raw + 1); // Signal type: round-robin let signal = signal_types[i % signal_types.len()]; // Weight: always 1.0 for count-based signals let weight = 1.0; // Timestamp: spread across the time span let offset = ((i as u64) * 104729 + 1) % span_nanos; let ts = base_time_nanos.saturating_sub(span_nanos) + offset; events.push((entity_id, signal, weight, ts)); } events } /// Count unique creators in a result set. fn creator_counts( results: &[tidaldb::query::retrieve::RetrieveResult], db: &TidalDB, ) -> HashMap { let mut counts: HashMap = HashMap::new(); for result in results { if let Ok(Some(meta)) = db.get_item_metadata(result.entity_id) { if let Some(creator_id) = meta.creator_id { *counts.entry(creator_id).or_insert(0) += 1; } } } counts } /// Get the category of an item from the database. fn item_category(db: &TidalDB, entity_id: EntityId) -> Option { db.get_item_metadata(entity_id) .ok() .flatten() .and_then(|m| m.category.clone()) } // ============================================================ // THE M2 UAT TEST // ============================================================ // // This is the definitive acceptance test for Milestone 2. // It matches the UAT scenario in ROADMAP.md. #[test] fn milestone_2_uat() { let dir = TempDir::new().unwrap(); let schema = m2_schema(); let db = TidalDB::open(Config { data_dir: dir.path().to_owned(), schema: schema.clone(), }) .unwrap(); // ============================================================ // Setup: Write 10K items with metadata and embeddings // ============================================================ let now = Timestamp::now(); let now_nanos = now.as_nanos(); for i in 0..10_000u64 { let (category, format, creator_id, created_at_offset) = item_metadata(i); let embedding = generate_embedding(i, 64); let created_at_nanos = now_nanos.saturating_sub(created_at_offset); db.write_item_with_metadata( EntityId::new(i + 1), &category, &format, creator_id, Timestamp::from_nanos(created_at_nanos), Some(&embedding), ) .unwrap(); } // Verify item count assert_eq!(db.item_count().unwrap(), 10_000); // ============================================================ // Setup: Write 10K signal events spanning 7 days // ============================================================ let seven_days_nanos = 7u64 * 24 * 3600 * 1_000_000_000; let events = generate_signal_events(10_000, 10_000, now_nanos, seven_days_nanos); for (entity_id, signal_type, weight, ts_nanos) in &events { db.signal(signal_type, *entity_id, *weight, Timestamp::from_nanos(*ts_nanos)) .unwrap(); } // ============================================================ // Query 1: Trending with diversity // ============================================================ // RETRIEVE items USING PROFILE trending DIVERSITY max_per_creator:1 LIMIT 25 let trending_query = Retrieve::builder() .entity(EntityKind::Item) .profile("trending") .diversity(DiversityConstraints::new().max_per_creator(1)) .limit(25) .build() .unwrap(); let trending_results = db.retrieve(&trending_query).unwrap(); // Verify: got results (up to 25) assert!( !trending_results.is_empty(), "trending query should return results" ); assert!( trending_results.len() <= 25, "trending query should return at most 25 results, got {}", trending_results.len() ); // Verify: scores are sorted descending for pair in trending_results.items.windows(2) { assert!( pair[0].score >= pair[1].score, "trending results should be sorted descending: {} >= {} (ranks {} and {})", pair[0].score, pair[1].score, pair[0].rank, pair[1].rank, ); } // Verify: creator diversity (max 1 per creator) let creators = creator_counts(&trending_results.items, &db); for (creator_id, count) in &creators { assert!( *count <= 1, "max_per_creator:1 violated: creator {} appears {} times", creator_id, count, ); } // Verify: ranks are 1-based and sequential for (i, item) in trending_results.items.iter().enumerate() { assert_eq!( item.rank, i + 1, "rank should be 1-based sequential, got {} at position {}", item.rank, i, ); } // ============================================================ // Query 2: Hot with category filter // ============================================================ // RETRIEVE items FILTER category:jazz USING PROFILE hot LIMIT 20 let jazz_query = Retrieve::builder() .entity(EntityKind::Item) .profile("hot") .filter(FilterExpr::eq("category", "jazz")) .limit(20) .build() .unwrap(); let jazz_results = db.retrieve(&jazz_query).unwrap(); // Verify: only jazz items returned for item in &jazz_results.items { let category = item_category(&db, item.entity_id); assert_eq!( category.as_deref(), Some("jazz"), "hot+jazz query returned non-jazz item: entity={}, category={:?}", item.entity_id, category, ); } // Verify: scores are sorted descending for pair in jazz_results.items.windows(2) { assert!( pair[0].score >= pair[1].score, "jazz results should be sorted descending: {} >= {}", pair[0].score, pair[1].score, ); } // ============================================================ // Query 3: New (created_at descending) // ============================================================ // RETRIEVE items USING PROFILE new LIMIT 20 let new_query = Retrieve::builder() .entity(EntityKind::Item) .profile("new") .limit(20) .build() .unwrap(); let new_results = db.retrieve(&new_query).unwrap(); assert!( !new_results.is_empty(), "new query should return results" ); assert!( new_results.len() <= 20, "new query should return at most 20 results" ); // Verify: scores are sorted descending (new profile uses created_at as score) for pair in new_results.items.windows(2) { assert!( pair[0].score >= pair[1].score, "new results should be sorted descending: {} >= {} (entities {} and {})", pair[0].score, pair[1].score, pair[0].entity_id, pair[1].entity_id, ); } // ============================================================ // Query 4: Top week (signal-based ordering within 7d window) // ============================================================ // RETRIEVE items USING PROFILE top_week LIMIT 20 let top_week_query = Retrieve::builder() .entity(EntityKind::Item) .profile("top_week") .limit(20) .build() .unwrap(); let top_week_results = db.retrieve(&top_week_query).unwrap(); assert!( !top_week_results.is_empty(), "top_week query should return results" ); // Verify: scores are sorted descending for pair in top_week_results.items.windows(2) { assert!( pair[0].score >= pair[1].score, "top_week results should be sorted descending: {} >= {}", pair[0].score, pair[1].score, ); } // ============================================================ // Query 5: Hidden gems // ============================================================ // ROADMAP UAT: RETRIEVE items USING PROFILE hidden_gems FILTER min_completion_rate:0.7 LIMIT 10 // // M2 limitation: `min_completion_rate` is a signal-derived filter (completion // rate = completion_count / view_count). The m2p2 filter engine supports // metadata field filters (BitmapIndex, RangeIndex) but not computed signal // ratios. Signal-derived predicates are an M3+ extension to the filter engine. // For M2, the hidden_gems query runs without the completion rate filter; // all items are candidates and the hidden_gems scoring formula naturally // surfaces items with high completion-to-view ratios. let hidden_gems_query = Retrieve::builder() .entity(EntityKind::Item) .profile("hidden_gems") // TODO M3: add .filter(FilterExpr::signal_ratio("completion", "view", 0.7)) // once signal-derived predicates are supported in the filter engine. .limit(10) .build() .unwrap(); let hidden_gems_results = db.retrieve(&hidden_gems_query).unwrap(); assert!( !hidden_gems_results.is_empty(), "hidden_gems query should return results" ); // Verify: scores are sorted descending for pair in hidden_gems_results.items.windows(2) { assert!( pair[0].score >= pair[1].score, "hidden_gems results should be sorted descending: {} >= {}", pair[0].score, pair[1].score, ); } // ============================================================ // Query 6: Controversial (dual-signal ranking) // ============================================================ // RETRIEVE items USING PROFILE controversial LIMIT 10 let controversial_query = Retrieve::builder() .entity(EntityKind::Item) .profile("controversial") .limit(10) .build() .unwrap(); let controversial_results = db.retrieve(&controversial_query).unwrap(); assert!( !controversial_results.is_empty(), "controversial query should return results" ); // Verify: scores are sorted descending for pair in controversial_results.items.windows(2) { assert!( pair[0].score >= pair[1].score, "controversial results should be sorted descending: {} >= {}", pair[0].score, pair[1].score, ); } // ============================================================ // Signal Burst: Write 100 "share" signals for item #500 // ============================================================ // Record pre-burst trending results let pre_burst_trending = Retrieve::builder() .entity(EntityKind::Item) .profile("trending") .limit(50) .build() .unwrap(); let pre_burst_results = db.retrieve(&pre_burst_trending).unwrap(); let pre_burst_rank = pre_burst_results .items .iter() .position(|r| r.entity_id == EntityId::new(500)); // Write 100 "share" signals for item #500 at the current time let burst_time = Timestamp::now(); for _ in 0..100 { db.signal("share", EntityId::new(500), 1.0, burst_time) .unwrap(); } // Re-execute trending query let post_burst_results = db.retrieve(&pre_burst_trending).unwrap(); let post_burst_rank = post_burst_results .items .iter() .position(|r| r.entity_id == EntityId::new(500)); // Verify: item #500 should be present (or rose from absent to present) // and its rank should have improved (or appeared) match (pre_burst_rank, post_burst_rank) { (None, Some(rank)) => { // Item was not in the top 50 before, now it is -- signal burst worked assert!( rank < 50, "item #500 should appear in top 50 after burst, found at position {}", rank ); } (Some(pre), Some(post)) => { // Item was in top 50 and should have moved up assert!( post <= pre, "item #500 should rank higher after burst: pre={}, post={}", pre, post ); } (None, None) => { // If item #500 still does not appear in top 50 after 100 share signals, // check that it at least has a higher score than before. // This can happen if the item is in a crowded ranking. // We verify signal write worked by reading the signal directly. let share_count = db .read_windowed_count(EntityId::new(500), "share", Window::AllTime) .unwrap(); assert!( share_count >= 100, "item #500 should have at least 100 shares after burst, got {}", share_count ); } (Some(_), None) => { panic!( "item #500 was in trending before burst but disappeared after -- this is wrong" ); } } // ============================================================ // Crash Recovery: Shutdown and reopen // ============================================================ db.shutdown().unwrap(); let db2 = TidalDB::open(Config { data_dir: dir.path().to_owned(), schema: schema.clone(), }) .unwrap(); // Re-verify: items survived assert_eq!( db2.item_count().unwrap(), 10_000, "item count should survive restart" ); // Re-verify: trending query still works let recovered_trending = Retrieve::builder() .entity(EntityKind::Item) .profile("trending") .limit(25) .build() .unwrap(); let recovered_results = db2.retrieve(&recovered_trending).unwrap(); assert!( !recovered_results.is_empty(), "trending query should work after restart" ); // Re-verify: scores are sorted descending after restart for pair in recovered_results.items.windows(2) { assert!( pair[0].score >= pair[1].score, "trending results after restart should be sorted: {} >= {}", pair[0].score, pair[1].score, ); } // Re-verify: hot+jazz filter still works let recovered_jazz = Retrieve::builder() .entity(EntityKind::Item) .profile("hot") .filter(FilterExpr::eq("category", "jazz")) .limit(20) .build() .unwrap(); let recovered_jazz_results = db2.retrieve(&recovered_jazz).unwrap(); for item in &recovered_jazz_results.items { let category = item_category(&db2, item.entity_id); assert_eq!( category.as_deref(), Some("jazz"), "jazz filter should still work after restart" ); } // Re-verify: signal burst for item #500 survived let recovered_share_count = db2 .read_windowed_count(EntityId::new(500), "share", Window::AllTime) .unwrap(); assert!( recovered_share_count >= 100, "share signals for item #500 should survive restart, got {}", recovered_share_count ); db2.shutdown().unwrap(); } // ============================================================ // SIGNAL SNAPSHOT TRANSPARENCY TEST // ============================================================ // // Verifies that RETRIEVE results include signal snapshots // for debugging and ranking transparency. #[test] fn retrieve_results_include_signal_snapshots() { let dir = TempDir::new().unwrap(); let schema = m2_schema(); let db = TidalDB::open(Config { data_dir: dir.path().to_owned(), schema, }) .unwrap(); // Write 100 items with embeddings for i in 0..100u64 { let (category, format, creator_id, created_at_offset) = item_metadata(i); let embedding = generate_embedding(i, 64); let now = Timestamp::now(); db.write_item_with_metadata( EntityId::new(i + 1), &category, &format, creator_id, Timestamp::from_nanos(now.as_nanos().saturating_sub(created_at_offset)), Some(&embedding), ) .unwrap(); } // Write enough signals so profiles have data to score with let now = Timestamp::now(); for i in 0..500u64 { let entity = EntityId::new((i % 100) + 1); db.signal("view", entity, 1.0, now).unwrap(); if i % 3 == 0 { db.signal("like", entity, 1.0, now).unwrap(); } } // Query with hot profile let query = Retrieve::builder() .profile("hot") .limit(10) .build() .unwrap(); let results = db.retrieve(&query).unwrap(); // At least some results should have signal snapshots let has_snapshots = results .items .iter() .any(|r| !r.signal_snapshot.is_empty()); assert!( has_snapshots, "at least some results should include signal snapshots" ); // Signal snapshots should be capped at 10 for item in &results.items { assert!( item.signal_snapshot.len() <= 10, "signal snapshot should be capped at 10, got {}", item.signal_snapshot.len() ); } db.shutdown().unwrap(); } // ============================================================ // EXCLUDE LIST TEST // ============================================================ // // Verifies that EXCLUDE IDs are removed from results. #[test] fn retrieve_excludes_specified_ids() { let dir = TempDir::new().unwrap(); let schema = m2_schema(); let db = TidalDB::open(Config { data_dir: dir.path().to_owned(), schema, }) .unwrap(); // Write 50 items for i in 0..50u64 { let (category, format, creator_id, created_at_offset) = item_metadata(i); let embedding = generate_embedding(i, 64); let now = Timestamp::now(); db.write_item_with_metadata( EntityId::new(i + 1), &category, &format, creator_id, Timestamp::from_nanos(now.as_nanos().saturating_sub(created_at_offset)), Some(&embedding), ) .unwrap(); } // Write signals let now = Timestamp::now(); for i in 0..200u64 { let entity = EntityId::new((i % 50) + 1); db.signal("view", entity, 1.0, now).unwrap(); } // Query without excludes let query_no_exclude = Retrieve::builder() .profile("hot") .limit(20) .build() .unwrap(); let results_no_exclude = db.retrieve(&query_no_exclude).unwrap(); // Pick the top 3 IDs to exclude let exclude_ids: Vec = results_no_exclude .items .iter() .take(3) .map(|r| r.entity_id) .collect(); // Query with excludes let query_with_exclude = Retrieve::builder() .profile("hot") .exclude_ids(exclude_ids.clone()) .limit(20) .build() .unwrap(); let results_with_exclude = db.retrieve(&query_with_exclude).unwrap(); // Verify: excluded IDs are not in results for item in &results_with_exclude.items { assert!( !exclude_ids.contains(&item.entity_id), "excluded entity {} should not appear in results", item.entity_id, ); } db.shutdown().unwrap(); } // ============================================================ // PAGINATION TEST // ============================================================ // // Verifies that offset-based cursor pagination works correctly // in the absence of concurrent writes. Note: offset cursors are // NOT stable under concurrent signal writes (the ranked list can // shift between pages). This test only covers the non-concurrent // case. See Cursor doc in task-01 for the full limitation note. #[test] fn retrieve_pagination_via_cursor() { let dir = TempDir::new().unwrap(); let schema = m2_schema(); let db = TidalDB::open(Config { data_dir: dir.path().to_owned(), schema, }) .unwrap(); // Write 100 items for i in 0..100u64 { let (category, format, creator_id, created_at_offset) = item_metadata(i); let embedding = generate_embedding(i, 64); let now = Timestamp::now(); db.write_item_with_metadata( EntityId::new(i + 1), &category, &format, creator_id, Timestamp::from_nanos(now.as_nanos().saturating_sub(created_at_offset)), Some(&embedding), ) .unwrap(); } // Write signals let now = Timestamp::now(); for i in 0..500u64 { let entity = EntityId::new((i % 100) + 1); db.signal("view", entity, 1.0, now).unwrap(); } // Page 1: first 10 results let page1_query = Retrieve::builder() .profile("hot") .limit(10) .build() .unwrap(); let page1 = db.retrieve(&page1_query).unwrap(); assert_eq!(page1.len(), 10, "page 1 should have 10 results"); assert!( page1.next_cursor.is_some(), "page 1 should have a next cursor" ); // Page 2: next 10 results using cursor let page2_query = Retrieve::builder() .profile("hot") .limit(10) .cursor(page1.next_cursor.unwrap()) .build() .unwrap(); let page2 = db.retrieve(&page2_query).unwrap(); assert_eq!(page2.len(), 10, "page 2 should have 10 results"); // Verify: no overlap between pages let page1_ids: Vec = page1.items.iter().map(|r| r.entity_id).collect(); let page2_ids: Vec = page2.items.iter().map(|r| r.entity_id).collect(); for id in &page2_ids { assert!( !page1_ids.contains(id), "entity {} appears on both page 1 and page 2", id, ); } // Verify: page 2 ranks continue from page 1 assert_eq!(page2.items[0].rank, 11, "page 2 should start at rank 11"); db.shutdown().unwrap(); } // ============================================================ // QUERY VALIDATION ERROR TEST // ============================================================ // // Verifies that invalid queries produce clear errors. #[test] fn retrieve_rejects_invalid_queries() { let dir = TempDir::new().unwrap(); let schema = m2_schema(); let db = TidalDB::open(Config { data_dir: dir.path().to_owned(), schema, }) .unwrap(); // Unknown profile let unknown_profile = Retrieve::builder() .profile("nonexistent_profile") .limit(10) .build() .unwrap(); let result = db.retrieve(&unknown_profile); assert!( matches!(result, Err(tidaldb::query::retrieve::QueryError::ProfileNotFound(_))), "unknown profile should return ProfileNotFound, got: {:?}", result, ); // Limit = 0 (caught at builder level) let result = Retrieve::builder().profile("new").limit(0).build(); assert!( matches!(result, Err(tidaldb::query::retrieve::QueryError::InvalidLimit { .. })), "limit=0 should return InvalidLimit" ); // Limit > 500 (caught at builder level) let result = Retrieve::builder().profile("new").limit(501).build(); assert!( matches!(result, Err(tidaldb::query::retrieve::QueryError::InvalidLimit { .. })), "limit=501 should return InvalidLimit" ); db.shutdown().unwrap(); } // ============================================================ // DETERMINISTIC RESULTS TEST // ============================================================ // // Verifies INV-QUERY-1: same query with same state produces // identical results. #[test] fn retrieve_deterministic_results() { let dir = TempDir::new().unwrap(); let schema = m2_schema(); let db = TidalDB::open(Config { data_dir: dir.path().to_owned(), schema, }) .unwrap(); // Write 100 items with signals let now = Timestamp::now(); for i in 0..100u64 { let (category, format, creator_id, created_at_offset) = item_metadata(i); let embedding = generate_embedding(i, 64); db.write_item_with_metadata( EntityId::new(i + 1), &category, &format, creator_id, Timestamp::from_nanos(now.as_nanos().saturating_sub(created_at_offset)), Some(&embedding), ) .unwrap(); db.signal("view", EntityId::new(i + 1), 1.0, now).unwrap(); if i % 3 == 0 { db.signal("like", EntityId::new(i + 1), 1.0, now) .unwrap(); } } let query = Retrieve::builder() .profile("hot") .limit(20) .build() .unwrap(); let results1 = db.retrieve(&query).unwrap(); let results2 = db.retrieve(&query).unwrap(); assert_eq!(results1.len(), results2.len(), "result counts must match"); for (r1, r2) in results1.items.iter().zip(results2.items.iter()) { assert_eq!( r1.entity_id, r2.entity_id, "entity IDs must match at rank {}", r1.rank, ); assert!( (r1.score - r2.score).abs() < f64::EPSILON, "scores must be identical for entity {} at rank {}: {} vs {}", r1.entity_id, r1.rank, r1.score, r2.score, ); } db.shutdown().unwrap(); } ``` ## Acceptance Criteria - [ ] `milestone_2_uat` test passes: all 6 queries return correctly ordered results - [ ] Query 1 (trending): results sorted descending, creator diversity enforced (max 1 per creator), ranks are 1-based sequential - [ ] Query 2 (hot + jazz filter): only jazz items returned, sorted descending by hot score - [ ] Query 3 (new): results sorted by created_at descending - [ ] Query 4 (top_week): results sorted by 7d signal-based score - [ ] Query 5 (hidden_gems): results sorted by quality/reach ratio - [ ] Query 6 (controversial): results sorted by dual-signal score - [ ] Signal burst: writing 100 "share" signals for item #500 causes it to rise in trending rank (or appear if previously absent) - [ ] Crash recovery: shutdown and reopen preserves all items, signals, and query functionality - [ ] `retrieve_results_include_signal_snapshots` test passes: at least some results have non-empty snapshots, all capped at 10 - [ ] `retrieve_excludes_specified_ids` test passes: excluded IDs never appear in results - [ ] `retrieve_pagination_via_cursor` test passes: pages do not overlap, ranks continue correctly - [ ] `retrieve_rejects_invalid_queries` test passes: clear errors for unknown profile, invalid limit - [ ] `retrieve_deterministic_results` test passes: same query produces identical results (INV-QUERY-1) - [ ] `cargo test --test m2_uat` passes - [ ] No `unsafe` code in tests - [ ] Test data is deterministic (fixed seeds, reproducible event sequences) ## Research References - [docs/research/tidaldb_signal_ledger.md](../../../research/tidaldb_signal_ledger.md) -- Signal write/read latencies referenced in test timing expectations - [docs/research/ann_for_tidaldb.md](../../../research/ann_for_tidaldb.md) -- ANN recall@k expectations for verifying retrieval correctness ## Spec References - [docs/specs/08-query-engine.md](../../../specs/08-query-engine.md) -- Section 2 (RETRIEVE operation), Section 5 (execution pipeline), Section 8 (pagination), Section 15 (invariants: INV-QUERY-1 deterministic, INV-QUERY-2 filter correctness) - [docs/specs/09-ranking-scoring.md](../../../specs/09-ranking-scoring.md) -- Section 11 (sort mode formulas verified by query ordering), Section 16 (INV-RANK-1 deterministic scoring, INV-RANK-5 diversity never reduces result count) ## Implementation Notes - **Signal count (10K vs ROADMAP's 100K)**: The ROADMAP UAT specifies 100K signal events. This test uses 10K to keep `cargo test --test m2_uat` under 30 seconds. 10K signals across 10K items averages 1 signal per entity — sparse but sufficient for correctness testing of ranking logic. For scale validation, add a `#[ignore]` test: ```rust #[test] #[ignore = "scale test: takes 2-3 minutes, run with --ignored"] fn milestone_2_uat_100k_signals() { // same as milestone_2_uat but with 100K signals } ``` Run with: `cargo test --test m2_uat -- --ignored milestone_2_uat_100k_signals` - The `generate_embedding` function uses `sin()` for deterministic pseudo-random vectors. The embeddings are L2-normalized so they work correctly with USearch's cosine/L2 equivalence. Use 64 dimensions for test speed -- the trait abstraction handles any dimension. - The `generate_signal_events` function uses prime strides (7919, 104729) for reproducible distribution without a PRNG dependency. The distribution is power-law-ish: some entities get more events than others, creating interesting ranking dynamics. - The `write_item_with_metadata` API is a convenience wrapper expected to exist on `TidalDb` for M2. If it does not exist, this task must add it. It stores structured metadata (category, format, creator_id, created_at) that the bitmap/range indexes and the RETRIEVE executor can read. The exact API shape depends on how metadata is stored after m2p2 (bitmap indexes) is integrated. - The signal burst test (100 "share" signals for item #500) verifies signal freshness: a signal written during the test is reflected in the very next query. The test handles the case where item #500 does not appear in the top 50 before or after the burst (possible with random signal distribution) by falling back to verifying the signal count directly. - The crash recovery section re-verifies item count, trending query, jazz filter, and signal persistence. It does NOT require exact score-level equality with pre-crash results (decay scores advance with time, so scores computed at a later time after restart will differ slightly). It verifies functional correctness: queries work, filters apply, signals survived. - Test execution time target: < 30 seconds for the full `m2_uat` test. At 10K items with 64-dim embeddings and 10K signals, setup should take ~5 seconds (item writes + signal writes), and the 6 queries should each take < 100ms. If the test is too slow, reduce item count to 5K or embedding dimension to 32. - All test assertions include descriptive failure messages. A failing assertion should tell the developer exactly what went wrong and which UAT step failed. - The `m2_uat.rs` file is an integration test (in `tidal/tests/`), not a unit test (in `src/`). It links against the compiled crate and tests the public API exactly as a user would.