tidaldb/tidal/src/query/search/scope/tests.rs
2026-02-23 22:41:16 -07:00

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, &registry);
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, &registry);
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));
}