//! Per-user state bitmap indexes. //! //! Tracks seen items, blocked creators/items, saved items, liked items, //! and completion progress. //! //! # Durability //! //! `seen`, `blocked`, `follows`, and `creator_followers` are rebuilt on open //! from durable relationship edges (`rebuild_entity_state`). The //! `completion` / `saved` / `liked` / `save_timestamps` maps have no //! relationship edge — they are driven by `completion` / `save` / `like` signals //! — so they are persisted write-through as durable `Tag::UserState` rows by the //! signal path and restored on open via [`UserStateIndex::restore`]. Without //! that, the `InProgress` filter and `DateSaved` sort silently reset to empty on //! every restart. use std::collections::HashSet; use dashmap::DashMap; use roaring::{RoaringBitmap, RoaringTreemap}; use super::CreatorItemsBitmap; use crate::{ schema::EntityId, storage::{StorageEngine, Tag, encode_key, entity_tag_prefix, parse_key}, }; /// Narrow a `u64` item-side entity ID into the `u32` slot used by every /// per-user item bitmap (seen / hidden / saved / liked), emitting a /// `tracing::warn!` if the ID exceeds `u32::MAX`. /// /// The item universe is bounded to `u32::MAX` (~4 billion) because the /// in-memory candidate-generation indexes key on a `u32` slot. The primary /// write path (`db/items.rs::write_item_with_metadata`) *rejects* an /// over-range item ID before it ever lands in storage, so in a well-formed /// database this narrowing never overflows. The relationship-driven write /// paths (the `Hide` edge in `db/relationships.rs` and its replay in /// `db/state_rebuild.rs`) reach this index with a `to` id that was *not* /// funneled through that guard, so they must route their `u64 -> u32` /// narrowing through this helper to keep every truncation observable and /// consistent with the warning `db/items.rs` already emits. Truncation is /// preserved (the documented universe limit is unchanged); only the silent /// part is fixed. /// /// Out-of-scope note: `db/relationships.rs` (the `Hide` relationship write / /// delete) and `db/state_rebuild.rs` (the `Hide` replay) currently perform a /// bare `to.as_u64() as u32`; they should call this helper instead. Those /// files are not edited here. #[must_use] pub(crate) fn narrow_item_slot(item_id: u64) -> u32 { if item_id > u64::from(u32::MAX) { // SLOT-NARROW: mirrors the warn db/items.rs emits when rejecting an // over-range item ID, so a Hide on such an ID is equally observable. tracing::warn!( item_id, limit = u32::MAX, "item id exceeds u32 item-slot universe; narrowing aliases a lower id (silent cross-id collision in per-user bitmaps)" ); } // Documented, intentional truncation to the u32 item slot. #[allow(clippy::cast_possible_truncation)] { item_id as u32 } } /// Blocked state: both individual items and all items from blocked creators. #[derive(Debug, Default)] pub struct BlockedState { pub creators: HashSet, pub items: RoaringBitmap, } /// Per-user state bitmaps for filtering during ranking. /// /// Thread-safe via `DashMap` -- no mutex on the hot path. Each user's /// state is independent, so concurrent updates to different users never /// contend. pub struct UserStateIndex { seen: DashMap, blocked: DashMap, saved: DashMap, liked: DashMap, /// `user_id` -> (`item_id` -> completion ratio). /// /// Keyed per-user rather than by a flat `(user_id, item_id)` so that /// [`in_progress_items`](Self::in_progress_items) scans only one user's /// completion entries instead of the global map. The threshold is applied /// at query time, so the inner map keeps the raw ratio (it cannot be /// pre-bucketed by an "in-progress" flag that depends on a per-call /// threshold). completion: DashMap>, /// `user_id` -> set of followed creator IDs. /// Populated from `RelationshipType::Follows` edges on startup and /// maintained in real-time by `write_relationship` / `delete_relationship`. follows: DashMap>, /// Reverse index: `creator_id` -> set of follower user IDs. /// /// Populated from `RelationshipType::Follows` edges. Updated on every /// `write_relationship` / `delete_relationship`. Rebuilt from durable /// relationship storage on open -- no separate persistence needed. /// /// Backed by a `RoaringTreemap` (64-bit) so the **full** `u64` user ID is /// preserved. The item-side bitmaps elsewhere in this index legitimately use /// `u32` because item IDs are dense and bounded, but user IDs are not -- a /// `u32` here silently aliased high user IDs onto each other, corrupting the /// follower graph for any deployment with > 4B users or sparse ID schemes. creator_followers: DashMap, /// M6p3: save timestamps for `DateSaved` sort. /// Maps `(user_id, item_id_u64)` -> `saved_at_ns`. save_timestamps: DashMap<(u64, u64), u64>, } impl UserStateIndex { #[must_use] pub fn new() -> Self { Self { seen: DashMap::new(), blocked: DashMap::new(), saved: DashMap::new(), liked: DashMap::new(), completion: DashMap::new(), follows: DashMap::new(), creator_followers: DashMap::new(), save_timestamps: DashMap::new(), } } // ── Seen ──────────────────────────────────────────────────────────── pub fn mark_seen(&self, user_id: u64, item_id: u32) { self.seen.entry(user_id).or_default().insert(item_id); } #[must_use] pub fn is_seen(&self, user_id: u64, item_id: u32) -> bool { self.seen .get(&user_id) .is_some_and(|bm| bm.contains(item_id)) } /// Get the seen bitmap for a user (cloned). #[must_use] pub fn seen_bitmap(&self, user_id: u64) -> RoaringBitmap { self.seen .get(&user_id) .map(|r| r.clone()) .unwrap_or_default() } // ── Blocked ───────────────────────────────────────────────────────── /// Hide a specific item for a user (item already narrowed to a `u32` slot). pub fn add_hide(&self, user_id: u64, item_id: u32) { self.blocked .entry(user_id) .or_default() .items .insert(item_id); } /// Hide a specific item for a user, narrowing the `u64` item ID to its /// `u32` slot through [`narrow_item_slot`] so an over-range ID logs a /// warning instead of silently aliasing a lower ID. /// /// This is the observable entry point the `Hide` relationship write path /// (`db/relationships.rs`) and its rebuild replay (`db/state_rebuild.rs`) /// should call instead of `add_hide(user_id, to.as_u64() as u32)`. pub fn add_hide_item(&self, user_id: u64, item_id: u64) { self.add_hide(user_id, narrow_item_slot(item_id)); } /// Un-hide a specific item for a user, narrowing the `u64` item ID to its /// `u32` slot through [`narrow_item_slot`]. Companion to /// [`add_hide_item`](Self::add_hide_item) for the `Hide` delete path. pub fn remove_hide_item(&self, user_id: u64, item_id: u64) { self.remove_hide(user_id, narrow_item_slot(item_id)); } /// Block an entire creator for a user. pub fn add_block_creator(&self, user_id: u64, creator_id: u64) { self.blocked .entry(user_id) .or_default() .creators .insert(creator_id); } /// Get hidden items bitmap for a user. #[must_use] pub fn hidden_items(&self, user_id: u64) -> RoaringBitmap { self.blocked .get(&user_id) .map(|state| state.items.clone()) .unwrap_or_default() } /// Get blocked creator IDs for a user. #[must_use] pub fn blocked_creators(&self, user_id: u64) -> HashSet { self.blocked .get(&user_id) .map(|state| state.creators.clone()) .unwrap_or_default() } /// Returns a predicate that checks whether an item ID is unseen for a user. #[must_use] pub fn unseen_predicate(&self, user_id: u64) -> Box bool + Send + Sync> { let seen = self.seen_bitmap(user_id); let hidden = self.hidden_items(user_id); Box::new(move |item_id: u32| !seen.contains(item_id) && !hidden.contains(item_id)) } /// Returns a predicate that checks whether an item is not from a blocked creator. /// /// Requires the `CreatorItemsBitmap` to expand creator blocks into item blocks. #[must_use] pub fn unblocked_predicate( &self, user_id: u64, creator_items: &CreatorItemsBitmap, ) -> Box bool + Send + Sync> { let blocked_creators = self.blocked_creators(user_id); let hidden = self.hidden_items(user_id); // Build a bitmap of all items from blocked creators. let mut blocked_items = hidden; for &cid in &blocked_creators { if let Some(bm) = creator_items.get(cid) { blocked_items |= &bm; } } Box::new(move |item_id: u32| !blocked_items.contains(item_id)) } /// Build a bitmap of all items from the given followed creators. /// /// The result is purely a function of `followed_creator_ids` and the /// creator->items map; it does not depend on any per-user state held by this /// index, so it takes no `user_id`. Callers resolve a user's followed /// creators via [`followed_creators_vec`](Self::followed_creators_vec) and /// pass them in. #[must_use] pub fn follows_bitmap( &self, followed_creator_ids: &[u64], creator_items: &CreatorItemsBitmap, ) -> RoaringBitmap { creator_items.union_for(followed_creator_ids) } /// Remove a creator block for a user. pub fn remove_block_creator(&self, user_id: u64, creator_id: u64) { if let Some(mut state) = self.blocked.get_mut(&user_id) { state.creators.remove(&creator_id); } } /// Remove a hidden item for a user (un-hide). /// /// Clears *only* the hidden flag. The seen bitmap is intentionally left /// untouched: hiding an item the user has already seen, then un-hiding it, /// must not resurface that item as UNSEEN. Seen-state and hidden-state are /// orthogonal -- `seen` records "the user encountered this", `blocked.items` /// records "the user asked to suppress this". Un-suppressing does not erase /// the fact that they saw it, so `unseen_predicate` keeps excluding it. pub fn remove_hide(&self, user_id: u64, item_id: u32) { if let Some(mut state) = self.blocked.get_mut(&user_id) { state.items.remove(item_id); } } // ── Follows ───────────────────────────────────────────────────────── /// Record that a user follows a creator. pub fn add_follow(&self, user_id: u64, creator_id: u64) { self.follows.entry(user_id).or_default().insert(creator_id); } /// Remove a follow relationship. pub fn remove_follow(&self, user_id: u64, creator_id: u64) { if let Some(mut set) = self.follows.get_mut(&user_id) { set.remove(&creator_id); } } /// Get the set of creator IDs this user follows. #[must_use] pub fn followed_creators(&self, user_id: u64) -> HashSet { self.follows .get(&user_id) .map(|r| r.clone()) .unwrap_or_default() } /// Get the followed creator IDs as a Vec (for convenience). #[must_use] pub fn followed_creators_vec(&self, user_id: u64) -> Vec { self.follows .get(&user_id) .map(|r| r.iter().copied().collect()) .unwrap_or_default() } // ── Creator Followers (reverse index) ──────────────────────────────── /// Add a follower to a creator's follower set. /// /// `creator_id` is the entity being followed; `follower_user_id` is the /// user doing the following. Stored at full `u64` precision in a /// `RoaringTreemap`. pub fn add_creator_follower(&self, creator_id: u64, follower_user_id: u64) { self.creator_followers .entry(creator_id) .or_default() .insert(follower_user_id); } /// Remove a follower from a creator's follower set. pub fn remove_creator_follower(&self, creator_id: u64, follower_user_id: u64) { if let Some(mut bm) = self.creator_followers.get_mut(&creator_id) { bm.remove(follower_user_id); } } /// Get the set of follower user IDs for a creator (as a 64-bit treemap, cloned). #[must_use] pub fn followers_bitmap(&self, creator_id: u64) -> RoaringTreemap { self.creator_followers .get(&creator_id) .map(|r| r.clone()) .unwrap_or_default() } /// Get follower user IDs for a creator as a `Vec`. #[must_use] pub fn follower_ids(&self, creator_id: u64) -> Vec { self.creator_followers .get(&creator_id) .map(|bm| bm.iter().collect()) .unwrap_or_default() } // ── Saved / Liked ─────────────────────────────────────────────────── pub fn add_save(&self, user_id: u64, item_id: u32) { self.saved.entry(user_id).or_default().insert(item_id); } /// Save an item with a timestamp (for `DateSaved` sort). /// /// Also adds the item to the saved bitmap for inclusion filters. pub fn add_save_timestamped(&self, user_id: u64, item_id: u32, timestamp_ns: u64) { self.saved.entry(user_id).or_default().insert(item_id); self.save_timestamps .insert((user_id, u64::from(item_id)), timestamp_ns); } /// Get the save timestamp for a (user, item) pair. /// /// Returns `None` if the item was not saved via `add_save_timestamped` or /// was saved via the older `add_save` (which does not record timestamps). #[must_use] pub fn get_save_timestamp(&self, user_id: u64, item_id: u64) -> Option { self.save_timestamps.get(&(user_id, item_id)).map(|r| *r) } #[must_use] pub fn is_saved(&self, user_id: u64, item_id: u32) -> bool { self.saved .get(&user_id) .is_some_and(|bm| bm.contains(item_id)) } /// Get the saved-items bitmap for a user (cloned). #[must_use] pub fn saved_bitmap(&self, user_id: u64) -> RoaringBitmap { self.saved .get(&user_id) .map(|r| r.clone()) .unwrap_or_default() } pub fn add_like(&self, user_id: u64, item_id: u32) { self.liked.entry(user_id).or_default().insert(item_id); } #[must_use] pub fn is_liked(&self, user_id: u64, item_id: u32) -> bool { self.liked .get(&user_id) .is_some_and(|bm| bm.contains(item_id)) } /// Get the liked-items bitmap for a user (cloned). #[must_use] pub fn liked_bitmap(&self, user_id: u64) -> RoaringBitmap { self.liked .get(&user_id) .map(|r| r.clone()) .unwrap_or_default() } // ── Completion ────────────────────────────────────────────────────── pub fn record_completion(&self, user_id: u64, item_id: u64, ratio: f64) { self.completion .entry(user_id) .or_default() .insert(item_id, ratio); } #[must_use] pub fn get_completion(&self, user_id: u64, item_id: u64) -> Option { self.completion .get(&user_id) .and_then(|items| items.get(&item_id).copied()) } /// Check if an item is "in progress" (0.0 < completion < threshold). #[must_use] pub fn is_in_progress(&self, user_id: u64, item_id: u64, threshold: f64) -> bool { self.completion .get(&user_id) .and_then(|items| items.get(&item_id).copied()) .is_some_and(|r| r > 0.0 && r < threshold) } /// List all item IDs with partial completion for a user (0 < v < threshold). /// /// Returns a `RoaringBitmap` of item IDs (narrowed to a u32 slot via /// [`narrow_item_slot`]) that satisfy the in-progress predicate. Used by /// Stage 2.5 of the executor to build the inclusion set for /// `FilterExpr::InProgress`. /// /// Scans only this user's completion entries — the map is keyed per-user, /// so the cost is `O(items completed by this user)`, not `O(global /// completion rows)` as it was when the map was keyed by a flat /// `(user_id, item_id)` tuple. #[must_use] pub fn in_progress_items(&self, user_id: u64, threshold: f64) -> RoaringBitmap { let mut bm = RoaringBitmap::new(); if let Some(items) = self.completion.get(&user_id) { for (&iid, &ratio) in items.iter() { if ratio > 0.0 && ratio < threshold { bm.insert(narrow_item_slot(iid)); } } } bm } } // ── Durable rows (completion / saved-timestamp / liked) ────────────────────── /// Discriminator byte for a `Tag::UserState` durable row, distinguishing the /// three signal-driven maps that share the sentinel `(0, Tag::UserState)` prefix. #[repr(u8)] #[derive(Clone, Copy, PartialEq, Eq)] enum UserStateRowKind { /// `(user_be8, item_be8)` -> completion ratio (8B LE f64 value). Completion = 0x01, /// `(user_be8, item_be8)` -> save timestamp ns (8B LE u64 value). Save = 0x02, /// `(user_be8, item_be8)` -> liked (empty value). Like = 0x03, } /// Encode a `Tag::UserState` key: `[kind: 1B][user: 8B BE][item: 8B BE]`. fn user_state_key(kind: UserStateRowKind, user_id: u64, item_id: u64) -> Vec { let mut suffix = [0u8; 17]; suffix[0] = kind as u8; suffix[1..9].copy_from_slice(&user_id.to_be_bytes()); suffix[9..].copy_from_slice(&item_id.to_be_bytes()); encode_key(EntityId::new(0), Tag::UserState, &suffix) } /// Decode a `Tag::UserState` suffix into `(kind_byte, user_id, item_id)`. fn decode_user_state_suffix(suffix: &[u8]) -> Option<(u8, u64, u64)> { if suffix.len() < 17 { return None; } let user_id = u64::from_be_bytes(suffix[1..9].try_into().ok()?); let item_id = u64::from_be_bytes(suffix[9..17].try_into().ok()?); Some((suffix[0], user_id, item_id)) } impl UserStateIndex { /// Persist a completion ratio write-through as a durable `Tag::UserState` row, /// then update the in-memory map. Mirrors [`record_completion`](Self::record_completion). /// /// # Errors /// /// Returns storage errors from the underlying engine. pub fn record_completion_durable( &self, storage: &dyn StorageEngine, user_id: u64, item_id: u64, ratio: f64, ) -> crate::Result<()> { let key = user_state_key(UserStateRowKind::Completion, user_id, item_id); storage .put(&key, &ratio.to_le_bytes()) .map_err(crate::schema::TidalError::from)?; self.record_completion(user_id, item_id, ratio); Ok(()) } /// Persist a timestamped save write-through, then update the in-memory map. /// Mirrors [`add_save_timestamped`](Self::add_save_timestamped). /// /// # Errors /// /// Returns storage errors from the underlying engine. pub fn add_save_durable( &self, storage: &dyn StorageEngine, user_id: u64, item_id: u32, timestamp_ns: u64, ) -> crate::Result<()> { let key = user_state_key(UserStateRowKind::Save, user_id, u64::from(item_id)); storage .put(&key, ×tamp_ns.to_le_bytes()) .map_err(crate::schema::TidalError::from)?; self.add_save_timestamped(user_id, item_id, timestamp_ns); Ok(()) } /// Persist a like write-through, then update the in-memory map. /// Mirrors [`add_like`](Self::add_like). /// /// # Errors /// /// Returns storage errors from the underlying engine. pub fn add_like_durable( &self, storage: &dyn StorageEngine, user_id: u64, item_id: u32, ) -> crate::Result<()> { let key = user_state_key(UserStateRowKind::Like, user_id, u64::from(item_id)); storage .put(&key, &[]) .map_err(crate::schema::TidalError::from)?; self.add_like(user_id, item_id); Ok(()) } /// Restore the completion / saved-timestamp / liked maps from durable /// `Tag::UserState` rows on open. Targeted prefix scan over the sentinel /// entity (`0`), so restore is `O(user_state_rows)`. /// /// # Errors /// /// Returns storage errors from the underlying engine. #[allow(clippy::cast_possible_truncation)] pub fn restore(&self, storage: &dyn StorageEngine) -> crate::Result<()> { let prefix = entity_tag_prefix(EntityId::new(0), Tag::UserState); let mut count = 0u64; for entry in storage.scan_prefix(&prefix) { let (key, value) = entry.map_err(crate::schema::TidalError::from)?; let Some((_, Tag::UserState, suffix)) = parse_key(&key) else { continue; }; let Some((kind, user_id, item_id)) = decode_user_state_suffix(suffix) else { continue; }; match kind { k if k == UserStateRowKind::Completion as u8 => { if value.len() >= 8 { let ratio = f64::from_le_bytes(value[..8].try_into().unwrap_or([0u8; 8])); self.record_completion(user_id, item_id, ratio); count += 1; } } k if k == UserStateRowKind::Save as u8 => { if value.len() >= 8 { let ts = u64::from_le_bytes(value[..8].try_into().unwrap_or([0u8; 8])); self.add_save_timestamped(user_id, item_id as u32, ts); count += 1; } } k if k == UserStateRowKind::Like as u8 => { self.add_like(user_id, item_id as u32); count += 1; } _ => {} } } if count > 0 { tracing::info!(rows = count, "user-state index restored from durable rows"); } Ok(()) } } impl Default for UserStateIndex { fn default() -> Self { Self::new() } } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::float_cmp)] mod tests { use super::*; #[test] fn seen_mark_and_check() { let idx = UserStateIndex::new(); assert!(!idx.is_seen(1, 10)); idx.mark_seen(1, 10); assert!(idx.is_seen(1, 10)); assert!(!idx.is_seen(1, 20)); assert!(!idx.is_seen(2, 10)); // different user } #[test] fn hide_item() { let idx = UserStateIndex::new(); idx.add_hide(1, 100); let hidden = idx.hidden_items(1); assert!(hidden.contains(100)); assert!(!hidden.contains(200)); } #[test] fn remove_hide_clears_only_hidden_not_seen() { // Regression: un-hiding an item the user has already seen must NOT // mark it unseen. Seen-state and hidden-state are orthogonal. let idx = UserStateIndex::new(); idx.mark_seen(1, 100); idx.add_hide(1, 100); // While hidden + seen, the item is excluded for both reasons. assert!(idx.is_seen(1, 100)); assert!(idx.hidden_items(1).contains(100)); assert!(!idx.unseen_predicate(1)(100)); // Un-hide: the hidden flag clears, but seen-state must survive. idx.remove_hide(1, 100); assert!(!idx.hidden_items(1).contains(100), "hidden flag must clear"); assert!(idx.is_seen(1, 100), "un-hide must not reset seen-state"); assert!( !idx.unseen_predicate(1)(100), "still-seen item must not resurface as unseen after un-hide" ); } #[test] fn narrow_item_slot_in_range_is_identity() { // Any id within the u32 universe narrows to itself with no warning. assert_eq!(narrow_item_slot(0), 0); assert_eq!(narrow_item_slot(123), 123); assert_eq!(narrow_item_slot(u64::from(u32::MAX)), u32::MAX); } #[test] fn narrow_item_slot_over_range_truncates() { // Over-range ids are truncated to the low 32 bits (the documented, // intentional universe limit); the warning makes the truncation // observable but the value contract is unchanged. assert_eq!(narrow_item_slot(u64::from(u32::MAX) + 1), 0); assert_eq!(narrow_item_slot((1u64 << 32) | 7), 7); } #[test] fn add_hide_item_routes_through_narrowing() { let idx = UserStateIndex::new(); // In-range id hides exactly that slot. idx.add_hide_item(1, 100); assert!(idx.hidden_items(1).contains(100)); // Over-range id narrows to its low-32 slot, matching narrow_item_slot. // (Low 32 bits = 42; the high bit is dropped by the narrowing.) idx.add_hide_item(1, (1u64 << 32) + 42); assert!(idx.hidden_items(1).contains(42)); // remove_hide_item un-hides through the same narrowing. idx.remove_hide_item(1, (1u64 << 32) + 42); assert!(!idx.hidden_items(1).contains(42)); // The unrelated in-range hide is untouched. assert!(idx.hidden_items(1).contains(100)); } #[test] fn block_creator() { let idx = UserStateIndex::new(); idx.add_block_creator(1, 77); let blocked = idx.blocked_creators(1); assert!(blocked.contains(&77)); } #[test] fn unseen_predicate_excludes_seen_and_hidden() { let idx = UserStateIndex::new(); idx.mark_seen(1, 10); idx.add_hide(1, 20); let pred = idx.unseen_predicate(1); assert!(!pred(10)); // seen assert!(!pred(20)); // hidden assert!(pred(30)); // neither } #[test] fn unblocked_predicate_excludes_blocked_creator_items() { let idx = UserStateIndex::new(); let creator_items = CreatorItemsBitmap::new(); creator_items.add_item(77, 100); creator_items.add_item(77, 101); idx.add_block_creator(1, 77); idx.add_hide(1, 200); let pred = idx.unblocked_predicate(1, &creator_items); assert!(!pred(100)); // from blocked creator assert!(!pred(101)); // from blocked creator assert!(!pred(200)); // hidden assert!(pred(300)); // ok } #[test] fn save_and_like() { let idx = UserStateIndex::new(); idx.add_save(1, 10); idx.add_like(1, 20); assert!(idx.is_saved(1, 10)); assert!(!idx.is_saved(1, 20)); assert!(idx.is_liked(1, 20)); assert!(!idx.is_liked(1, 10)); } #[test] fn saved_bitmap_returns_all_saved_items() { let idx = UserStateIndex::new(); idx.add_save(1, 10); idx.add_save(1, 20); idx.add_save(1, 30); idx.add_save(2, 99); // different user let bm = idx.saved_bitmap(1); assert_eq!(bm.len(), 3); assert!(bm.contains(10)); assert!(bm.contains(20)); assert!(bm.contains(30)); assert!(!bm.contains(99)); // belongs to user 2 // Unknown user returns empty bitmap. assert!(idx.saved_bitmap(999).is_empty()); } #[test] fn liked_bitmap_returns_all_liked_items() { let idx = UserStateIndex::new(); idx.add_like(1, 5); idx.add_like(1, 15); idx.add_like(2, 25); // different user let bm = idx.liked_bitmap(1); assert_eq!(bm.len(), 2); assert!(bm.contains(5)); assert!(bm.contains(15)); assert!(!bm.contains(25)); // Unknown user returns empty bitmap. assert!(idx.liked_bitmap(999).is_empty()); } // ── Creator Followers Tests ────────────────────────────────────────── #[test] fn add_creator_follower_and_query() { let idx = UserStateIndex::new(); idx.add_creator_follower(100, 1); // creator 100, follower user 1 idx.add_creator_follower(100, 2); idx.add_creator_follower(200, 3); let bm = idx.followers_bitmap(100); assert_eq!(bm.len(), 2); assert!(bm.contains(1)); assert!(bm.contains(2)); let ids = idx.follower_ids(100); assert_eq!(ids.len(), 2); assert!(ids.contains(&1)); assert!(ids.contains(&2)); // Creator 200 has only user 3. assert_eq!(idx.follower_ids(200), vec![3]); // Unknown creator returns empty. assert!(idx.followers_bitmap(999).is_empty()); assert!(idx.follower_ids(999).is_empty()); } #[test] fn remove_creator_follower() { let idx = UserStateIndex::new(); idx.add_creator_follower(100, 1); idx.add_creator_follower(100, 2); idx.remove_creator_follower(100, 1); let bm = idx.followers_bitmap(100); assert_eq!(bm.len(), 1); assert!(!bm.contains(1)); assert!(bm.contains(2)); // Removing from unknown creator is a no-op. idx.remove_creator_follower(999, 1); } #[test] fn creator_followers_preserve_full_u64() { // Regression: the reverse index previously stored follower IDs as u32, // which aliased high user IDs onto each other (e.g. `1` and // `1 + 2^32` collide once truncated). Full-u64 storage keeps them // distinct. let idx = UserStateIndex::new(); let low = 1u64; let high = 1u64 + (1u64 << 32); // shares low 32 bits with `low` let huge = u64::MAX - 7; // far outside u32 range idx.add_creator_follower(100, low); idx.add_creator_follower(100, high); idx.add_creator_follower(100, huge); let bm = idx.followers_bitmap(100); assert_eq!(bm.len(), 3, "u32 truncation would have collapsed low/high"); assert!(bm.contains(low)); assert!(bm.contains(high)); assert!(bm.contains(huge)); let ids = idx.follower_ids(100); assert_eq!(ids.len(), 3); assert!(ids.contains(&low)); assert!(ids.contains(&high)); assert!(ids.contains(&huge)); // Removing the truncation-colliding `high` must not affect `low`. idx.remove_creator_follower(100, high); let bm = idx.followers_bitmap(100); assert!(bm.contains(low)); assert!(!bm.contains(high)); assert!(bm.contains(huge)); } #[test] fn completion_tracking() { let idx = UserStateIndex::new(); idx.record_completion(1, 10, 0.5); assert_eq!(idx.get_completion(1, 10), Some(0.5)); assert!(idx.is_in_progress(1, 10, 0.8)); assert!(!idx.is_in_progress(1, 10, 0.4)); // 0.5 >= 0.4 idx.record_completion(1, 10, 0.9); assert!(!idx.is_in_progress(1, 10, 0.8)); // 0.9 >= 0.8 } #[test] fn in_progress_items_bitmap() { let idx = UserStateIndex::new(); idx.record_completion(1, 10, 0.3); // in progress idx.record_completion(1, 20, 0.9); // complete idx.record_completion(1, 30, 0.0); // not started idx.record_completion(1, 40, 0.5); // in progress idx.record_completion(2, 50, 0.4); // different user let bm = idx.in_progress_items(1, 0.8); assert_eq!(bm.len(), 2); assert!(bm.contains(10)); assert!(bm.contains(40)); assert!(!bm.contains(20)); // >= threshold assert!(!bm.contains(30)); // == 0.0 assert!(!bm.contains(50)); // different user } #[test] fn in_progress_items_isolates_users_under_shared_item_ids() { // Regression for the per-user completion keying: many users completing // the *same* item IDs must not bleed into each other's in-progress set. // Previously the global (user_id, item_id) map was scanned in full per // call; the per-user map makes each query touch only its own user. let idx = UserStateIndex::new(); for uid in 0..100u64 { // Every user has item 7 in progress and item 8 complete. idx.record_completion(uid, 7, 0.5); idx.record_completion(uid, 8, 0.95); } // User 42 additionally has item 9 in progress. idx.record_completion(42, 9, 0.25); let bm = idx.in_progress_items(42, 0.8); assert_eq!(bm.len(), 2, "only user 42's in-progress items"); assert!(bm.contains(7)); assert!(bm.contains(9)); assert!(!bm.contains(8)); // >= threshold // A user without the extra item sees only the shared in-progress item. let other = idx.in_progress_items(7, 0.8); assert_eq!(other.len(), 1); assert!(other.contains(7)); assert!(!other.contains(9)); // belongs to user 42 only // get_completion / is_in_progress agree with the per-user map. assert_eq!(idx.get_completion(42, 9), Some(0.25)); assert_eq!(idx.get_completion(7, 9), None); assert!(idx.is_in_progress(42, 9, 0.8)); assert!(!idx.is_in_progress(7, 9, 0.8)); } }