use std::time::Duration; use super::*; use crate::{ entities::collection::{Collection, Visibility}, schema::{DecaySpec, EntityKind, SchemaBuilder, Timestamp}, signals::ledger::types::NoopWalWriter, }; fn test_schema() -> crate::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( "share", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::OneHour, Window::TwentyFourHours, Window::AllTime]) .velocity(true) .add(); builder.build().expect("valid test schema") } fn build_universe(n: u32) -> RoaringBitmap { let mut bm = RoaringBitmap::new(); for i in 1..=n { bm.insert(i); } bm } // ── window_to_schema_window ───────────────────────────────────────── #[test] fn window_mapping() { assert_eq!(window_to_schema_window(1), Window::OneHour); assert_eq!(window_to_schema_window(12), Window::TwentyFourHours); assert_eq!(window_to_schema_window(24), Window::TwentyFourHours); assert_eq!(window_to_schema_window(48), Window::SevenDays); assert_eq!(window_to_schema_window(168), Window::SevenDays); assert_eq!(window_to_schema_window(200), Window::AllTime); } // ── percentile_bitmap ─────────────────────────────────────────────── #[test] fn percentile_small_n_keeps_all_positive() { // With n=4, p25_asc_idx = (4-1)*25/100 = 0, so the cutoff is the // minimum velocity. All items with vel > 0 are kept (degenerate case). let mut map = HashMap::new(); map.insert(1, 100); map.insert(2, 50); map.insert(3, 25); map.insert(4, 10); let bm = percentile_bitmap(&map); assert!(bm.contains(1)); assert!(bm.contains(2)); assert!(bm.contains(3)); assert!(bm.contains(4)); } #[test] fn percentile_large_n_keeps_top_75_percent() { // With n=100 items with velocities 1..=100, the p25 cutoff in ascending // order is at index (99*25/100)=24, velocity=25. Items with vel >= 25 // (i.e., IDs 24..=99, corresponding to vel 25..=100) should be included. // That is 76 items (vel 25 through 100 inclusive). let mut map = HashMap::new(); for i in 0u64..100 { map.insert(i, i + 1); // velocities 1..=100, ID == vel - 1 } let bm = percentile_bitmap(&map); // p25 cutoff: ascending index 24 → velocity = 25 // Items with vel >= 25: IDs 24..=99 (76 items) assert_eq!(bm.len(), 76, "expected 76 items above p75 cutoff for n=100"); // Highest-velocity item must be included. assert!(bm.contains(99), "ID 99 (vel=100) must be in top 75%"); // Lowest-velocity item must be excluded. assert!(!bm.contains(0), "ID 0 (vel=1) must be below p75 cutoff"); } #[test] fn percentile_excludes_zero_velocity() { let mut map = HashMap::new(); map.insert(1, 100); map.insert(2, 0); let bm = percentile_bitmap(&map); assert!(bm.contains(1)); assert!(!bm.contains(2)); } // ── resolve_category ──────────────────────────────────────────────── #[test] fn resolve_category_returns_matching_items() { let schema = test_schema(); let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter)); let cat_idx = BitmapIndex::new("category"); cat_idx.insert(1, "music"); cat_idx.insert(2, "music"); cat_idx.insert(3, "tech"); let resolver = ScopeResolver::new(&ledger, build_universe(3)).with_category_index(&cat_idx); let scope = WithinScope::Category { name: "music".into(), }; let bm = resolver.resolve(&scope, None).unwrap(); assert!(bm.contains(1)); assert!(bm.contains(2)); assert!(!bm.contains(3)); } #[test] fn resolve_category_unknown_returns_empty() { let schema = test_schema(); let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter)); let cat_idx = BitmapIndex::new("category"); let resolver = ScopeResolver::new(&ledger, build_universe(3)).with_category_index(&cat_idx); let scope = WithinScope::Category { name: "nonexistent".into(), }; let bm = resolver.resolve(&scope, None).unwrap(); assert!(bm.is_empty()); } // ── resolve_collection ────────────────────────────────────────────── #[test] fn resolve_collection_returns_items() { let schema = test_schema(); let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter)); let coll_idx = CollectionIndex::new(); let cid = coll_idx.next_collection_id(); coll_idx.create(Collection { id: cid, owner_id: 1, name: "favorites".into(), visibility: Visibility::Private, created_at_ns: 0, item_ids: vec![5, 10, 15], }); let resolver = ScopeResolver::new(&ledger, build_universe(20)).with_collection_index(&coll_idx); let scope = WithinScope::Collection { id: cid }; let bm = resolver.resolve(&scope, None).unwrap(); assert!(bm.contains(5)); assert!(bm.contains(10)); assert!(bm.contains(15)); assert!(!bm.contains(20)); } // ── resolve_following ─────────────────────────────────────────────── #[test] fn resolve_following_requires_for_user() { let schema = test_schema(); let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter)); let resolver = ScopeResolver::new(&ledger, build_universe(10)); let result = resolver.resolve(&WithinScope::Following, None); assert!(matches!(result, Err(QueryError::InvalidFilter { .. }))); } #[test] fn resolve_following_returns_creator_items() { let schema = test_schema(); let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter)); let user_state = UserStateIndex::new(); let creator_items = CreatorItemsBitmap::new(); // User 1 follows creator 100. user_state.add_follow(1, 100); // Creator 100 has items 10, 20. creator_items.add_item(100, 10); creator_items.add_item(100, 20); // Creator 200 has item 30 (not followed). creator_items.add_item(200, 30); let resolver = ScopeResolver::new(&ledger, build_universe(30)) .with_user_context(&user_state, &creator_items); let bm = resolver.resolve(&WithinScope::Following, Some(1)).unwrap(); assert!(bm.contains(10)); assert!(bm.contains(20)); assert!(!bm.contains(30)); } // ── resolve_trending ──────────────────────────────────────────────── #[test] fn resolve_trending_returns_universe_when_no_signals() { let schema = test_schema(); let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter)); let universe = build_universe(5); let resolver = ScopeResolver::new(&ledger, universe.clone()); let bm = resolver .resolve(&WithinScope::Trending { window_hours: 24 }, None) .unwrap(); assert_eq!(bm, universe); } #[test] fn resolve_trending_filters_by_velocity() { let schema = test_schema(); let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter)); let ts = Timestamp::now(); // Item 1: 10 views, Item 2: 5 views, Item 3: 1 view, Item 4: 0 views. for _ in 0..10 { ledger .record_signal("view", crate::schema::EntityId::new(1), 1.0, ts) .unwrap(); } for _ in 0..5 { ledger .record_signal("view", crate::schema::EntityId::new(2), 1.0, ts) .unwrap(); } ledger .record_signal("view", crate::schema::EntityId::new(3), 1.0, ts) .unwrap(); let resolver = ScopeResolver::new(&ledger, build_universe(4)); let bm = resolver .resolve(&WithinScope::Trending { window_hours: 1 }, None) .unwrap(); // All signaled items should be included (they all have vel > 0). assert!(bm.contains(1)); assert!(bm.contains(2)); assert!(bm.contains(3)); // Item 4 has no signals, so it should be excluded. assert!(!bm.contains(4)); } // ── resolve_cohort_trending ───────────────────────────────────────── #[test] fn resolve_cohort_trending_unknown_cohort_returns_error() { let schema = test_schema(); let ledger = SignalLedger::new(schema.clone(), Box::new(NoopWalWriter)); let registry = CohortRegistry::new(); let cohort_ledger = CohortSignalLedger::new(&schema); let resolver = ScopeResolver::new(&ledger, build_universe(5)).with_cohort(&cohort_ledger, ®istry); let scope = WithinScope::CohortTrending { cohort: "nonexistent".into(), window_hours: 24, }; let result = resolver.resolve(&scope, None); assert!(matches!(result, Err(QueryError::NotFound(_)))); } #[test] fn resolve_cohort_trending_returns_top_items() { use crate::cohort::{CohortDef, Predicate}; let schema = test_schema(); let ledger = SignalLedger::new(schema.clone(), Box::new(NoopWalWriter)); let registry = CohortRegistry::new(); registry .define(CohortDef { name: "tech".into(), predicate: Predicate::Eq { field: "interest".into(), value: "tech".into(), }, }) .unwrap(); let cohort_ledger = CohortSignalLedger::new(&schema); let view_id = cohort_ledger.resolve_signal_type("view").unwrap(); let ts = Timestamp::now().as_nanos(); // Record signals in cohort. let tech: std::sync::Arc = std::sync::Arc::from("tech"); for _ in 0..10 { cohort_ledger.record(&tech, crate::schema::EntityId::new(1), view_id, 1.0, ts); } cohort_ledger.record(&tech, crate::schema::EntityId::new(2), view_id, 1.0, ts); let resolver = ScopeResolver::new(&ledger, build_universe(3)).with_cohort(&cohort_ledger, ®istry); let scope = WithinScope::CohortTrending { cohort: "tech".into(), window_hours: 1, }; let bm = resolver.resolve(&scope, None).unwrap(); assert!(bm.contains(1)); assert!(bm.contains(2)); }