311 lines
10 KiB
Rust
311 lines
10 KiB
Rust
use std::time::Duration;
|
|
|
|
use super::*;
|
|
use crate::entities::collection::{Collection, Visibility};
|
|
use crate::schema::{DecaySpec, EntityKind, SchemaBuilder, Timestamp};
|
|
use crate::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() {
|
|
let schema = test_schema();
|
|
let ledger = SignalLedger::new(schema.clone(), Box::new(NoopWalWriter));
|
|
let registry = CohortRegistry::new();
|
|
use crate::cohort::{CohortDef, Predicate};
|
|
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.
|
|
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));
|
|
}
|