tidaldb/tidal/src/entities/user_state.rs
jx12n b55ad70141 fix: M0-M10 third-pass remediation — durability, replication, and CLI hardening
Resolves the 142 findings from tidal/docs/reviews/CODE_REVIEW_m0-m10.md across
the engine, server, net, and CLI surfaces:

- WAL/session-journal durability, checkpoint format, and crash-recovery hardening
- Replication shipper/receiver, tenant isolation, and migration paths
- Cluster scatter-gather, router, standalone server + health/offload endpoints
- tidalctl refactored into command modules with JSON output and WAL-state tooling
- Cohort, governance, signal-ledger, and vector-registry correctness fixes
- Expanded UAT/integration/durability test coverage across all milestones
2026-06-08 10:28:34 -06:00

917 lines
34 KiB
Rust

//! 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<u64>,
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<u64, RoaringBitmap>,
blocked: DashMap<u64, BlockedState>,
saved: DashMap<u64, RoaringBitmap>,
liked: DashMap<u64, RoaringBitmap>,
/// `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<u64, std::collections::HashMap<u64, f64>>,
/// `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<u64, HashSet<u64>>,
/// 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<u64, RoaringTreemap>,
/// 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<u64> {
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<dyn Fn(u32) -> 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<dyn Fn(u32) -> 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<u64> {
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<u64> {
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<u64>`.
#[must_use]
pub fn follower_ids(&self, creator_id: u64) -> Vec<u64> {
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<u64> {
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<f64> {
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<u8> {
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, &timestamp_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));
}
}