# Task 01: User State Filters

## Context

**Milestone:** 3 -- Personalized Ranking
**Phase:** m3p4 -- User State Filters + M3 UAT Integration Test
**Depends On:** m3p3 Task 01 (`FOR USER` query context, `UserContext` loading), m3p2 Task 04 (atomic signal dispatch: seen bitmaps populated, hard negatives enforced), m3p1 Task 03 (`UserStateIndex` with seen/blocked/hidden/follows), m2p2 (filter engine: `FilterExpr`, `FilterResult`, `FilterEvaluator`)
**Blocks:** Task 02 (M3 UAT Integration Test exercises user-state filters as part of the full query)
**Complexity:** M

## Objective

Extend the `FilterExpr` AST and the RETRIEVE executor to support user-state filters: `unseen`, `unblocked`, `saved`, `liked`, and `in_progress`. These filters depend on per-user state (seen bitmaps, blocked sets, saved/liked bitmaps, completion signals) and require the `FOR USER` clause to resolve user context. When a query includes `FILTER unseen, unblocked, category:jazz`, the executor evaluates `unseen` and `unblocked` against the user's state, evaluates `category:jazz` against the bitmap index, and intersects the results.

User-state filters produce `FilterResult::Predicate` (not `FilterResult::Bitmap`) because they depend on the querying user and cannot be precomputed as static bitmaps. The RETRIEVE executor must handle the intersection of predicate-based and bitmap-based filter results during candidate evaluation.

The `saved` and `liked` filters require per-user bitmaps of saved/liked item IDs. These bitmaps are maintained by the signal dispatch from m3p2 Task 04: when a "save" signal is written, the item is added to the user's saved bitmap; when a "like" signal is written, the item is added to the user's liked bitmap. Saving an item also marks it as seen.

The `in_progress` filter requires reading the user's completion signals for each item. An item is "in progress" if it has a completion signal with value > 0.0 and < 0.8 (configurable threshold). Items with completion >= 0.8 are considered "completed" and excluded from in_progress results.

Using a user-state filter without `FOR USER` produces a `LumenError::Query` error with a descriptive message. This prevents confusing results where unseen/unblocked filters would silently pass everything.

## Requirements

- `FilterExpr` extended with five new variants: `Unseen`, `Unblocked`, `Saved`, `Liked`, `InProgress`
- Query parser recognizes `unseen`, `unblocked`, `saved`, `liked`, `in_progress` as filter keywords
- User-state filter keywords can be mixed with metadata filters: `FILTER unseen, unblocked, category:jazz, format:video`
- Comma-separated filters are implicitly ANDed
- User-state filters produce `FilterResult::Predicate` (not bitmap)
- `FilterEvaluator` extended with optional `UserContext` reference for user-state evaluation
- When `UserContext` is not provided and a user-state filter is present, return `LumenError::Query`
- RETRIEVE executor intersects user-state predicates with metadata filter bitmaps
- `UserStateIndex` extended with `saved` and `liked` per-user bitmaps
- `UserStateIndex::mark_saved(user_id, item_id)` and `UserStateIndex::mark_liked(user_id, item_id)`
- Signal dispatch for "save" writes: add to saved bitmap + mark_seen
- Signal dispatch for "like" writes: add to liked bitmap (mark_seen is already handled by view signal)
- `in_progress` filter evaluates completion signal for each item per-user
- `InProgressConfig` with configurable completion threshold (default min: 0.0, max: 0.8)
- All five user-state filters compose with each other: `FILTER unseen, unblocked, saved` is valid (returns unseen, unblocked saved items)

## Technical Design

### Module Structure

```
tidal/src/
  storage/
    indexes/
      filter.rs       -- FilterExpr extended with user-state variants
      user_filter.rs   -- User-state filter evaluation logic (new file)
  query/
    mod.rs            -- Parser extended to recognize user-state filter keywords
  entities/
    user_state.rs     -- Extended with saved/liked bitmaps
  db/
    signal_dispatch.rs -- Extended to maintain saved/liked bitmaps
```

### FilterExpr Extensions

```rust
// === storage/indexes/filter.rs (extensions) ===

/// A filter expression AST node.
#[derive(Debug, Clone, PartialEq)]
pub enum FilterExpr {
    // --- Existing metadata filters ---
    CategoryEq(String),
    FormatEq(String),
    CreatorEq(u32),
    Tag(String),
    DurationMin(u32),
    DurationMax(u32),
    CreatedAfter(u64),
    CreatedBefore(u64),
    And(Vec<FilterExpr>),
    Or(Vec<FilterExpr>),
    Not(Box<FilterExpr>),

    // --- User-state filters (M3) ---

    /// Exclude items the user has viewed.
    /// Requires `FOR USER` clause.
    Unseen,
    /// Exclude items from blocked creators and hidden items.
    /// Requires `FOR USER` clause.
    Unblocked,
    /// Return only items the user has saved.
    /// Requires `FOR USER` clause.
    Saved,
    /// Return only items the user has liked.
    /// Requires `FOR USER` clause.
    Liked,
    /// Return items with partial completion (started but not finished).
    /// Requires `FOR USER` clause.
    InProgress,
}

impl FilterExpr {
    /// Whether this filter variant requires user context.
    pub fn requires_user_context(&self) -> bool {
        match self {
            Self::Unseen | Self::Unblocked | Self::Saved | Self::Liked | Self::InProgress => true,
            Self::And(children) => children.iter().any(|c| c.requires_user_context()),
            Self::Or(children) => children.iter().any(|c| c.requires_user_context()),
            Self::Not(inner) => inner.requires_user_context(),
            _ => false,
        }
    }
}
```

### User-State Filter Evaluator

```rust
// === storage/indexes/user_filter.rs (new file) ===

use crate::db::user_context::UserContext;
use crate::entities::user_state::UserStateIndex;
use crate::schema::EntityId;
use crate::signals::SignalLedger;
use super::filter::{FilterExpr, FilterResult};

/// Configuration for the in_progress filter.
#[derive(Debug, Clone)]
pub struct InProgressConfig {
    /// Minimum completion value to be considered "in progress".
    /// Items with completion <= this are not started. Default: 0.0.
    pub min_completion: f64,
    /// Maximum completion value to be considered "in progress".
    /// Items with completion >= this are considered "completed".
    /// Default: 0.8.
    pub max_completion: f64,
}

impl Default for InProgressConfig {
    fn default() -> Self {
        Self {
            min_completion: 0.0,
            max_completion: 0.8,
        }
    }
}

/// Evaluates user-state filter expressions against the user's state.
///
/// Unlike `FilterEvaluator` which operates on bitmap indexes (static,
/// shared across all users), this evaluator produces per-user predicates
/// that check the querying user's specific state.
pub struct UserFilterEvaluator<'a> {
    user_ctx: &'a UserContext,
    user_state: &'a UserStateIndex,
    ledger: &'a SignalLedger,
    in_progress_config: InProgressConfig,
    /// Closure to look up creator_id for an item.
    /// Needed by `unblocked` filter to check if the item's creator is blocked.
    item_creator_lookup: Box<dyn Fn(u64) -> Option<u64> + Send + Sync + 'a>,
}

impl<'a> UserFilterEvaluator<'a> {
    pub fn new(
        user_ctx: &'a UserContext,
        user_state: &'a UserStateIndex,
        ledger: &'a SignalLedger,
        in_progress_config: InProgressConfig,
        item_creator_lookup: Box<dyn Fn(u64) -> Option<u64> + Send + Sync + 'a>,
    ) -> Self {
        Self {
            user_ctx,
            user_state,
            ledger,
            in_progress_config,
            item_creator_lookup,
        }
    }

    /// Evaluate a user-state filter expression.
    ///
    /// Returns `FilterResult::Predicate` for user-state filters.
    /// Returns `None` for non-user-state filters (caller should use
    /// `FilterEvaluator` instead).
    pub fn evaluate(&self, expr: &FilterExpr) -> Option<FilterResult> {
        match expr {
            FilterExpr::Unseen => Some(self.eval_unseen()),
            FilterExpr::Unblocked => Some(self.eval_unblocked()),
            FilterExpr::Saved => Some(self.eval_saved()),
            FilterExpr::Liked => Some(self.eval_liked()),
            FilterExpr::InProgress => Some(self.eval_in_progress()),
            _ => None, // Not a user-state filter
        }
    }

    fn eval_unseen(&self) -> FilterResult {
        let predicate = self.user_state.unseen_predicate(self.user_ctx.user_id);
        FilterResult::Predicate(predicate)
    }

    fn eval_unblocked(&self) -> FilterResult {
        // Clone the blocked state for the closure.
        let blocked_creators = self.user_ctx.blocked_creators.clone();
        let hidden_items = self.user_ctx.hidden_items.clone();
        let item_creator = {
            // Build a lookup from the executor's metadata.
            // The creator lookup is injected at construction time.
            let lookup = self.user_state.unblocked_predicate(self.user_ctx.user_id);
            lookup
        };

        // The unblocked predicate from UserStateIndex takes (item_id, Option<creator_id>).
        // We need to wrap it into a Fn(u64) -> bool that uses the item_creator_lookup
        // to resolve the creator_id for each item.
        let blocked_creators_clone = blocked_creators.clone();
        let hidden_items_clone = hidden_items.clone();

        // Build a predicate that checks both hidden items and blocked creators.
        FilterResult::Predicate(Box::new(move |item_id: u64| {
            // Check hidden items.
            if hidden_items_clone.contains(&(item_id as u32)) {
                return false;
            }
            // Blocked creator check deferred to candidate evaluation
            // where creator_id is available from ScoredCandidate.
            // For bitmap-level filtering, we only check hidden items here.
            // The executor will do the creator-level block check when it has
            // access to the candidate's creator_id.
            //
            // Note: this is a partial check. The full unblocked check happens
            // in the candidate evaluation stage. Hidden items are caught here;
            // blocked creators are caught during scoring where creator_id is known.
            true
        }))
    }

    fn eval_saved(&self) -> FilterResult {
        let predicate = self.user_state.saved_predicate(self.user_ctx.user_id);
        FilterResult::Predicate(predicate)
    }

    fn eval_liked(&self) -> FilterResult {
        let predicate = self.user_state.liked_predicate(self.user_ctx.user_id);
        FilterResult::Predicate(predicate)
    }

    fn eval_in_progress(&self) -> FilterResult {
        let user_id = self.user_ctx.user_id;
        let min = self.in_progress_config.min_completion;
        let max = self.in_progress_config.max_completion;

        // The in_progress filter needs per-item completion signal data.
        // Read from the ledger: for each candidate, check if the user has
        // a completion signal with value in (min, max).
        //
        // Implementation: we read the user's completion signals from the
        // user_state index (which tracks completion per item via signal dispatch).
        let predicate = self.user_state.in_progress_predicate(user_id, min, max);
        FilterResult::Predicate(predicate)
    }
}
```

### Unblocked Filter: Full Creator Check in Executor

The `unblocked` filter has a two-stage evaluation:

1. **Bitmap-level (early)**: hidden items are excluded by the predicate returned from `eval_unblocked()`. This catches items the user has explicitly hidden.

2. **Candidate-level (during scoring)**: blocked creators are excluded during the scoring/selection pass, where each `ScoredCandidate` has a `creator_id` field. The RETRIEVE executor checks the user context's `blocked_creators` set.

```rust
// In the RETRIEVE executor, after scoring candidates:

impl TidalDb {
    fn apply_user_state_filters(
        &self,
        candidates: Vec<ScoredCandidate>,
        user_ctx: &UserContext,
    ) -> Vec<ScoredCandidate> {
        candidates.into_iter()
            .filter(|c| {
                // Exclude items from blocked creators.
                if let Some(creator_id) = c.creator_id {
                    if user_ctx.blocked_creators.contains(&creator_id) {
                        return false;
                    }
                }
                // Exclude hidden items (redundant with predicate filter,
                // but provides defense-in-depth).
                if user_ctx.hidden_items.contains(&(c.entity_id.as_u64() as u32)) {
                    return false;
                }
                true
            })
            .collect()
    }
}
```

### UserStateIndex Extensions for Saved/Liked

```rust
// === entities/user_state.rs (extensions) ===

impl UserStateIndex {
    // The existing fields are: seen, blocked, follows.
    // Add: saved, liked, completion (for in_progress).

    // New fields in UserStateIndex:
    // saved: DashMap<u64, RoaringBitmap>,   -- per-user saved item bitmaps
    // liked: DashMap<u64, RoaringBitmap>,   -- per-user liked item bitmaps
    // completion: DashMap<(u64, u32), f64>, -- per-(user, item) completion value

    /// Mark an item as saved by a user.
    pub fn mark_saved(&self, user_id: EntityId, item_id: EntityId) {
        self.saved
            .entry(user_id.as_u64())
            .or_default()
            .insert(item_id.as_u64() as u32);
    }

    /// Check if a user has saved an item.
    pub fn is_saved(&self, user_id: EntityId, item_id: EntityId) -> bool {
        self.saved
            .get(&user_id.as_u64())
            .map_or(false, |bm| bm.contains(item_id.as_u64() as u32))
    }

    /// Remove an item from a user's saved set.
    pub fn unmark_saved(&self, user_id: EntityId, item_id: EntityId) {
        if let Some(mut bm) = self.saved.get_mut(&user_id.as_u64()) {
            bm.remove(item_id.as_u64() as u32);
        }
    }

    /// Mark an item as liked by a user.
    pub fn mark_liked(&self, user_id: EntityId, item_id: EntityId) {
        self.liked
            .entry(user_id.as_u64())
            .or_default()
            .insert(item_id.as_u64() as u32);
    }

    /// Check if a user has liked an item.
    pub fn is_liked(&self, user_id: EntityId, item_id: EntityId) -> bool {
        self.liked
            .get(&user_id.as_u64())
            .map_or(false, |bm| bm.contains(item_id.as_u64() as u32))
    }

    /// Remove an item from a user's liked set (unlike).
    pub fn unmark_liked(&self, user_id: EntityId, item_id: EntityId) {
        if let Some(mut bm) = self.liked.get_mut(&user_id.as_u64()) {
            bm.remove(item_id.as_u64() as u32);
        }
    }

    /// Record a completion value for a user-item pair.
    pub fn set_completion(&self, user_id: EntityId, item_id: EntityId, value: f64) {
        self.completion
            .insert((user_id.as_u64(), item_id.as_u64() as u32), value);
    }

    /// Read the completion value for a user-item pair.
    pub fn get_completion(&self, user_id: EntityId, item_id: EntityId) -> Option<f64> {
        self.completion
            .get(&(user_id.as_u64(), item_id.as_u64() as u32))
            .map(|v| *v)
    }

    // ── Predicate builders for new filters ──────────────────

    /// Build a "saved" filter predicate for a user.
    ///
    /// Returns a closure that returns `true` for items the user HAS saved.
    pub fn saved_predicate(
        &self,
        user_id: EntityId,
    ) -> Box<dyn Fn(u64) -> bool + Send + Sync> {
        let saved_bitmap = self.saved
            .get(&user_id.as_u64())
            .map(|bm| bm.clone());
        Box::new(move |item_id: u64| {
            match &saved_bitmap {
                Some(bm) => bm.contains(item_id as u32),
                None => false, // no saved data = nothing is saved
            }
        })
    }

    /// Build a "liked" filter predicate for a user.
    ///
    /// Returns a closure that returns `true` for items the user HAS liked.
    pub fn liked_predicate(
        &self,
        user_id: EntityId,
    ) -> Box<dyn Fn(u64) -> bool + Send + Sync> {
        let liked_bitmap = self.liked
            .get(&user_id.as_u64())
            .map(|bm| bm.clone());
        Box::new(move |item_id: u64| {
            match &liked_bitmap {
                Some(bm) => bm.contains(item_id as u32),
                None => false, // no liked data = nothing is liked
            }
        })
    }

    /// Build an "in_progress" filter predicate for a user.
    ///
    /// Returns a closure that returns `true` for items the user has started
    /// but not finished (completion value between min and max exclusive).
    pub fn in_progress_predicate(
        &self,
        user_id: EntityId,
        min_completion: f64,
        max_completion: f64,
    ) -> Box<dyn Fn(u64) -> bool + Send + Sync> {
        // Snapshot the completion data for this user.
        let completions: Vec<(u32, f64)> = self.completion
            .iter()
            .filter(|entry| entry.key().0 == user_id.as_u64())
            .map(|entry| (entry.key().1, *entry.value()))
            .collect();
        let completion_map: std::collections::HashMap<u32, f64> =
            completions.into_iter().collect();

        Box::new(move |item_id: u64| {
            match completion_map.get(&(item_id as u32)) {
                Some(&value) => value > min_completion && value < max_completion,
                None => false, // no completion data = not in progress
            }
        })
    }
}
```

### Query Parser Extension

```rust
// === query/mod.rs (extensions for filter parsing) ===

/// Parse a single filter token.
///
/// Recognizes both metadata filters (key:value format) and
/// user-state filters (bare keywords).
fn parse_single_filter(token: &str) -> crate::Result<FilterExpr> {
    match token {
        "unseen" => Ok(FilterExpr::Unseen),
        "unblocked" => Ok(FilterExpr::Unblocked),
        "saved" => Ok(FilterExpr::Saved),
        "liked" => Ok(FilterExpr::Liked),
        "in_progress" => Ok(FilterExpr::InProgress),
        _ => {
            // Try key:value metadata filter.
            if let Some((key, value)) = token.split_once(':') {
                parse_metadata_filter(key, value)
            } else {
                Err(LumenError::Query(format!(
                    "unknown filter: '{}'. Expected a user-state filter (unseen, unblocked, \
                     saved, liked, in_progress) or a metadata filter (key:value)",
                    token
                )))
            }
        }
    }
}

/// Parse the FILTER clause.
///
/// Filters are comma-separated and implicitly ANDed:
/// `FILTER unseen, unblocked, category:jazz` becomes
/// `And([Unseen, Unblocked, CategoryEq("jazz")])`.
fn parse_filter_clause(tokens: &[Token], pos: &mut usize) -> crate::Result<Vec<FilterExpr>> {
    if !tokens.get(*pos).map_or(false, |t| t.is_keyword("FILTER")) {
        return Ok(vec![]);
    }
    *pos += 1;

    let mut filters = vec![];
    loop {
        if *pos >= tokens.len() {
            break;
        }
        let token = &tokens[*pos];
        if token.is_keyword("DIVERSITY") || token.is_keyword("LIMIT")
            || token.is_keyword("USING") || token.is_keyword("EXCLUDE")
        {
            break; // Next clause
        }

        let filter = parse_single_filter(token.as_str())?;
        filters.push(filter);
        *pos += 1;

        // Skip optional comma separator.
        if tokens.get(*pos).map_or(false, |t| t.as_str() == ",") {
            *pos += 1;
        }
    }

    Ok(filters)
}
```

### Executor Integration: Filter Evaluation Pipeline

```rust
// In the RETRIEVE executor:

impl TidalDb {
    fn evaluate_filters(
        &self,
        filters: &[FilterExpr],
        user_ctx: Option<&UserContext>,
    ) -> crate::Result<ComposedFilter> {
        // 1. Validate: any user-state filter requires user context.
        for f in filters {
            if f.requires_user_context() && user_ctx.is_none() {
                let name = match f {
                    FilterExpr::Unseen => "unseen",
                    FilterExpr::Unblocked => "unblocked",
                    FilterExpr::Saved => "saved",
                    FilterExpr::Liked => "liked",
                    FilterExpr::InProgress => "in_progress",
                    _ => "user-state",
                };
                return Err(LumenError::Query(format!(
                    "FILTER {} requires FOR USER clause", name
                )));
            }
        }

        // 2. Separate user-state filters from metadata filters.
        let (user_filters, metadata_filters): (Vec<_>, Vec<_>) = filters.iter()
            .partition(|f| matches!(f,
                FilterExpr::Unseen | FilterExpr::Unblocked |
                FilterExpr::Saved | FilterExpr::Liked | FilterExpr::InProgress
            ));

        // 3. Evaluate metadata filters via FilterEvaluator -> bitmaps.
        let metadata_bitmap = if metadata_filters.is_empty() {
            None
        } else {
            let combined = FilterExpr::And(
                metadata_filters.into_iter().cloned().collect()
            );
            let evaluator = self.build_filter_evaluator();
            Some(evaluator.evaluate(&combined).into_bitmap())
        };

        // 4. Evaluate user-state filters via UserFilterEvaluator -> predicates.
        let user_predicates: Vec<Box<dyn Fn(u64) -> bool + Send + Sync>> =
            if let Some(ctx) = user_ctx {
                let user_eval = UserFilterEvaluator::new(
                    ctx,
                    &self.user_state,
                    self.ledger.as_ref().unwrap(),
                    InProgressConfig::default(),
                    Box::new(|item_id| self.read_item_creator_id_u64(item_id)),
                );
                user_filters.iter()
                    .filter_map(|f| user_eval.evaluate(f))
                    .map(|r| r.into_predicate())
                    .collect()
            } else {
                vec![]
            };

        // 5. Return a composed filter that checks both bitmap and predicates.
        Ok(ComposedFilter {
            bitmap: metadata_bitmap,
            predicates: user_predicates,
        })
    }
}

/// A composed filter result combining bitmap and predicate evaluations.
pub struct ComposedFilter {
    /// Bitmap from metadata filters. `None` means no metadata filter (all pass).
    pub bitmap: Option<RoaringBitmap>,
    /// Predicate closures from user-state filters.
    pub predicates: Vec<Box<dyn Fn(u64) -> bool + Send + Sync>>,
}

impl ComposedFilter {
    /// Check if a candidate passes all filter layers.
    pub fn passes(&self, item_id: u64) -> bool {
        // Check bitmap membership.
        if let Some(ref bm) = self.bitmap {
            if !bm.contains(item_id as u32) {
                return false;
            }
        }
        // Check all user-state predicates.
        for pred in &self.predicates {
            if !pred(item_id) {
                return false;
            }
        }
        true
    }

    /// Iterate over bitmap candidates that also pass all predicates.
    ///
    /// When a bitmap is available, iterates over its members and filters
    /// by predicates. When no bitmap is available, the caller must provide
    /// a candidate set to filter.
    pub fn apply_to_candidates(&self, candidates: &[EntityId]) -> Vec<EntityId> {
        candidates.iter()
            .filter(|&eid| self.passes(eid.as_u64()))
            .copied()
            .collect()
    }
}
```

### Signal Dispatch Extensions for Saved/Liked

```rust
// Extensions to dispatch_user_signal in db/signal_dispatch.rs:

// In the match arm for signal types:
"save" => {
    // 1. Item signal ledger update.
    ledger.record_signal("save", item_id, weight, timestamp)?;

    // 2. Add to user's saved bitmap.
    user_state.mark_saved(user_ctx.user_id, item_id);

    // 3. Mark as seen (saving implies seeing).
    user_state.mark_seen(user_ctx.user_id, item_id);

    // 4. Update interaction weight (positive).
    if let Some(creator_id) = read_item_creator(item_id) {
        let delta = interaction_weights.delta_for_signal("save");
        if delta.abs() > f64::EPSILON {
            interaction_weights.update_weight(
                user_ctx.user_id, creator_id, delta, timestamp,
            );
        }
    }
}

"unsave" => {
    // Remove from saved bitmap.
    user_state.unmark_saved(user_ctx.user_id, item_id);
}

// The "like" arm (already exists) is extended:
"like" => {
    // Existing: item signal ledger, pref vector update, interaction weight.
    ledger.record_signal("like", item_id, weight, timestamp)?;

    // NEW: Add to user's liked bitmap.
    user_state.mark_liked(user_ctx.user_id, item_id);

    // ... existing preference vector and interaction weight logic ...
}

"unlike" => {
    // Remove from liked bitmap.
    user_state.unmark_liked(user_ctx.user_id, item_id);
}

"completion" => {
    // Existing: item signal ledger, pref vector update.
    ledger.record_signal("completion", item_id, weight, timestamp)?;

    // NEW: Track per-user completion for in_progress filter.
    user_state.set_completion(user_ctx.user_id, item_id, weight);

    // ... existing preference vector and interaction weight logic ...
}
```

## Test Strategy

### Unit Tests

```rust
#[test]
fn filter_expr_requires_user_context_for_unseen() {
    assert!(FilterExpr::Unseen.requires_user_context());
    assert!(FilterExpr::Unblocked.requires_user_context());
    assert!(FilterExpr::Saved.requires_user_context());
    assert!(FilterExpr::Liked.requires_user_context());
    assert!(FilterExpr::InProgress.requires_user_context());
}

#[test]
fn filter_expr_metadata_does_not_require_user_context() {
    assert!(!FilterExpr::CategoryEq("jazz".into()).requires_user_context());
    assert!(!FilterExpr::FormatEq("video".into()).requires_user_context());
    assert!(!FilterExpr::Tag("rock".into()).requires_user_context());
}

#[test]
fn filter_expr_and_propagates_user_context_requirement() {
    let and = FilterExpr::And(vec![
        FilterExpr::CategoryEq("jazz".into()),
        FilterExpr::Unseen,
    ]);
    assert!(and.requires_user_context());

    let metadata_only = FilterExpr::And(vec![
        FilterExpr::CategoryEq("jazz".into()),
        FilterExpr::FormatEq("video".into()),
    ]);
    assert!(!metadata_only.requires_user_context());
}

#[test]
fn unseen_filter_excludes_seen_items() {
    let user_state = UserStateIndex::new();
    let user = EntityId::new(1);
    user_state.mark_seen(user, EntityId::new(5));
    user_state.mark_seen(user, EntityId::new(10));

    let pred = user_state.unseen_predicate(user);
    assert!(!pred(5));   // seen -> filtered out
    assert!(!pred(10));  // seen -> filtered out
    assert!(pred(15));   // unseen -> passes
    assert!(pred(1));    // unseen -> passes
}

#[test]
fn saved_filter_includes_only_saved_items() {
    let user_state = UserStateIndex::new();
    let user = EntityId::new(1);
    user_state.mark_saved(user, EntityId::new(42));
    user_state.mark_saved(user, EntityId::new(99));

    let pred = user_state.saved_predicate(user);
    assert!(pred(42));   // saved -> passes
    assert!(pred(99));   // saved -> passes
    assert!(!pred(1));   // not saved -> filtered out
    assert!(!pred(100)); // not saved -> filtered out
}

#[test]
fn liked_filter_includes_only_liked_items() {
    let user_state = UserStateIndex::new();
    let user = EntityId::new(1);
    user_state.mark_liked(user, EntityId::new(42));

    let pred = user_state.liked_predicate(user);
    assert!(pred(42));   // liked -> passes
    assert!(!pred(1));   // not liked -> filtered out
}

#[test]
fn unlike_removes_from_liked() {
    let user_state = UserStateIndex::new();
    let user = EntityId::new(1);
    user_state.mark_liked(user, EntityId::new(42));
    assert!(user_state.is_liked(user, EntityId::new(42)));

    user_state.unmark_liked(user, EntityId::new(42));
    assert!(!user_state.is_liked(user, EntityId::new(42)));
}

#[test]
fn unsave_removes_from_saved() {
    let user_state = UserStateIndex::new();
    let user = EntityId::new(1);
    user_state.mark_saved(user, EntityId::new(42));
    assert!(user_state.is_saved(user, EntityId::new(42)));

    user_state.unmark_saved(user, EntityId::new(42));
    assert!(!user_state.is_saved(user, EntityId::new(42)));
}

#[test]
fn in_progress_filter_matches_partial_completion() {
    let user_state = UserStateIndex::new();
    let user = EntityId::new(1);

    // Not started: no completion record.
    // In progress: completion = 0.4.
    user_state.set_completion(user, EntityId::new(10), 0.4);
    // Completed: completion = 0.9.
    user_state.set_completion(user, EntityId::new(20), 0.9);
    // Just started: completion = 0.01.
    user_state.set_completion(user, EntityId::new(30), 0.01);

    let pred = user_state.in_progress_predicate(user, 0.0, 0.8);
    assert!(!pred(5));    // no completion -> not in progress
    assert!(pred(10));    // 0.4 in (0.0, 0.8) -> in progress
    assert!(!pred(20));   // 0.9 >= 0.8 -> completed, not in progress
    assert!(pred(30));    // 0.01 in (0.0, 0.8) -> in progress
}

#[test]
fn in_progress_zero_completion_not_in_progress() {
    let user_state = UserStateIndex::new();
    let user = EntityId::new(1);
    user_state.set_completion(user, EntityId::new(10), 0.0);

    let pred = user_state.in_progress_predicate(user, 0.0, 0.8);
    assert!(!pred(10)); // completion == 0.0, not > 0.0
}

#[test]
fn saved_predicate_for_unknown_user_excludes_all() {
    let user_state = UserStateIndex::new();
    let pred = user_state.saved_predicate(EntityId::new(999));
    assert!(!pred(1));   // no saved data -> nothing passes
    assert!(!pred(100));
}

#[test]
fn user_state_filter_without_for_user_returns_error() {
    let db = open_ephemeral_test_db();
    // Write some items.
    for i in 0..10 {
        let mut meta = HashMap::new();
        meta.insert("category".into(), "jazz".into());
        db.write_item(EntityId::new(i), &meta).unwrap();
    }

    // Query with unseen filter but NO FOR USER clause.
    let result = db.retrieve(
        "RETRIEVE items FILTER unseen USING PROFILE trending LIMIT 10"
    );
    assert!(result.is_err());
    let err = result.unwrap_err();
    assert!(err.to_string().contains("requires FOR USER"),
        "error message should indicate FOR USER is needed: {}", err);
}

#[test]
fn user_state_filter_with_for_user_succeeds() {
    let db = open_test_db_with_user_state();
    let result = db.retrieve(
        "RETRIEVE items FOR USER @42 FILTER unseen, unblocked \
         USING PROFILE for_you LIMIT 10"
    );
    assert!(result.is_ok());
}

#[test]
fn composed_filter_bitmap_and_predicate() {
    let mut bitmap = RoaringBitmap::new();
    for i in 0u32..100 {
        bitmap.insert(i);
    }

    let filter = ComposedFilter {
        bitmap: Some(bitmap),
        predicates: vec![
            Box::new(|id: u64| id % 2 == 0), // even only
        ],
    };

    assert!(filter.passes(0));    // in bitmap AND even
    assert!(!filter.passes(1));   // in bitmap but odd
    assert!(!filter.passes(200)); // not in bitmap
    assert!(filter.passes(50));   // in bitmap AND even
}

#[test]
fn composed_filter_no_bitmap_uses_predicates_only() {
    let filter = ComposedFilter {
        bitmap: None,
        predicates: vec![
            Box::new(|id: u64| id < 100),
        ],
    };

    assert!(filter.passes(0));
    assert!(filter.passes(99));
    assert!(!filter.passes(100));
}

#[test]
fn composed_filter_multiple_predicates_all_must_pass() {
    let filter = ComposedFilter {
        bitmap: None,
        predicates: vec![
            Box::new(|id: u64| id % 2 == 0), // even
            Box::new(|id: u64| id % 3 == 0), // divisible by 3
        ],
    };

    assert!(filter.passes(0));    // 0 is even AND div by 3
    assert!(filter.passes(6));    // 6 is even AND div by 3
    assert!(!filter.passes(2));   // even but not div by 3
    assert!(!filter.passes(3));   // div by 3 but odd
    assert!(!filter.passes(5));   // neither
}

#[test]
fn parse_unseen_filter() {
    let query = "RETRIEVE items FOR USER @42 FILTER unseen LIMIT 10";
    let parsed = parse_retrieve(query).unwrap();
    assert!(parsed.filters.contains(&FilterExpr::Unseen));
}

#[test]
fn parse_mixed_filters() {
    let query = "RETRIEVE items FOR USER @42 FILTER unseen, unblocked, category:jazz, format:video LIMIT 10";
    let parsed = parse_retrieve(query).unwrap();
    assert!(parsed.filters.contains(&FilterExpr::Unseen));
    assert!(parsed.filters.contains(&FilterExpr::Unblocked));
    assert!(parsed.filters.contains(&FilterExpr::CategoryEq("jazz".into())));
    assert!(parsed.filters.contains(&FilterExpr::FormatEq("video".into())));
}

#[test]
fn parse_saved_liked_in_progress() {
    let query = "RETRIEVE items FOR USER @42 FILTER saved LIMIT 10";
    let parsed = parse_retrieve(query).unwrap();
    assert!(parsed.filters.contains(&FilterExpr::Saved));

    let query2 = "RETRIEVE items FOR USER @42 FILTER liked LIMIT 10";
    let parsed2 = parse_retrieve(query2).unwrap();
    assert!(parsed2.filters.contains(&FilterExpr::Liked));

    let query3 = "RETRIEVE items FOR USER @42 FILTER in_progress LIMIT 10";
    let parsed3 = parse_retrieve(query3).unwrap();
    assert!(parsed3.filters.contains(&FilterExpr::InProgress));
}
```

### Property Tests

```rust
use proptest::prelude::*;

proptest! {
    #[test]
    fn seen_items_never_pass_unseen_filter(
        user_id in 1u64..100,
        seen_items in proptest::collection::vec(1u64..10000, 1..200),
        test_item in 1u64..10000,
    ) {
        let user_state = UserStateIndex::new();
        let user = EntityId::new(user_id);
        for &item in &seen_items {
            user_state.mark_seen(user, EntityId::new(item));
        }

        let pred = user_state.unseen_predicate(user);
        if seen_items.contains(&test_item) {
            prop_assert!(!pred(test_item),
                "seen item {} should NOT pass unseen filter", test_item);
        } else {
            prop_assert!(pred(test_item),
                "unseen item {} should pass unseen filter", test_item);
        }
    }

    #[test]
    fn saved_items_pass_saved_filter_and_others_do_not(
        saved_items in proptest::collection::hash_set(1u64..10000, 1..100),
        test_item in 1u64..10000,
    ) {
        let user_state = UserStateIndex::new();
        let user = EntityId::new(1);
        for &item in &saved_items {
            user_state.mark_saved(user, EntityId::new(item));
        }

        let pred = user_state.saved_predicate(user);
        if saved_items.contains(&test_item) {
            prop_assert!(pred(test_item),
                "saved item {} should pass saved filter", test_item);
        } else {
            prop_assert!(!pred(test_item),
                "unsaved item {} should NOT pass saved filter", test_item);
        }
    }

    #[test]
    fn liked_items_pass_liked_filter_and_others_do_not(
        liked_items in proptest::collection::hash_set(1u64..10000, 1..100),
        test_item in 1u64..10000,
    ) {
        let user_state = UserStateIndex::new();
        let user = EntityId::new(1);
        for &item in &liked_items {
            user_state.mark_liked(user, EntityId::new(item));
        }

        let pred = user_state.liked_predicate(user);
        if liked_items.contains(&test_item) {
            prop_assert!(pred(test_item),
                "liked item {} should pass liked filter", test_item);
        } else {
            prop_assert!(!pred(test_item),
                "unliked item {} should NOT pass liked filter", test_item);
        }
    }

    #[test]
    fn in_progress_only_matches_partial_completion(
        completions in proptest::collection::vec(
            (1u64..1000, 0.0f64..1.0),
            1..50
        ),
        test_item in 1u64..1000,
    ) {
        let user_state = UserStateIndex::new();
        let user = EntityId::new(1);
        let min = 0.0;
        let max = 0.8;

        for &(item, value) in &completions {
            user_state.set_completion(user, EntityId::new(item), value);
        }

        let pred = user_state.in_progress_predicate(user, min, max);
        let result = pred(test_item);

        let expected = completions.iter()
            .find(|(item, _)| *item == test_item)
            .map_or(false, |(_, value)| *value > min && *value < max);

        prop_assert_eq!(result, expected,
            "in_progress for item {} should be {}", test_item, expected);
    }

    #[test]
    fn composed_filter_bitmap_and_predicate_agree(
        bitmap_ids in proptest::collection::hash_set(0u32..1000, 10..200),
        predicate_fn_mod in 2u64..10,
        test_id in 0u64..1000,
    ) {
        let bitmap: RoaringBitmap = bitmap_ids.iter().copied().collect();
        let in_bitmap = bitmap.contains(test_id as u32);
        let passes_pred = test_id % predicate_fn_mod == 0;

        let filter = ComposedFilter {
            bitmap: Some(bitmap),
            predicates: vec![
                Box::new(move |id: u64| id % predicate_fn_mod == 0),
            ],
        };

        let expected = in_bitmap && passes_pred;
        prop_assert_eq!(filter.passes(test_id), expected,
            "composed filter: bitmap={}, pred={}, expected={}",
            in_bitmap, passes_pred, expected);
    }

    #[test]
    fn unseen_and_saved_compose_correctly(
        seen_items in proptest::collection::hash_set(1u64..500, 10..50),
        saved_items in proptest::collection::hash_set(1u64..500, 5..30),
        test_item in 1u64..500,
    ) {
        let user_state = UserStateIndex::new();
        let user = EntityId::new(1);

        for &item in &seen_items {
            user_state.mark_seen(user, EntityId::new(item));
        }
        for &item in &saved_items {
            user_state.mark_saved(user, EntityId::new(item));
        }

        let unseen_pred = user_state.unseen_predicate(user);
        let saved_pred = user_state.saved_predicate(user);

        // "FILTER unseen, saved" = unseen AND saved.
        // This would return items that are saved but not yet viewed.
        // (Unusual but valid combination.)
        let is_unseen = !seen_items.contains(&test_item);
        let is_saved = saved_items.contains(&test_item);
        let expected = is_unseen && is_saved;
        let actual = unseen_pred(test_item) && saved_pred(test_item);

        prop_assert_eq!(actual, expected,
            "unseen={}, saved={}, expected={}", is_unseen, is_saved, expected);
    }
}
```

## Acceptance Criteria

- [ ] `FilterExpr` extended with `Unseen`, `Unblocked`, `Saved`, `Liked`, `InProgress` variants
- [ ] `FilterExpr::requires_user_context()` correctly identifies user-state variants
- [ ] Query parser recognizes `unseen`, `unblocked`, `saved`, `liked`, `in_progress` as filter keywords
- [ ] Mixed filters parse correctly: `FILTER unseen, category:jazz, format:video`
- [ ] User-state filters without `FOR USER` return `LumenError::Query` error
- [ ] `UserStateIndex` extended with `saved`, `liked`, `completion` structures
- [ ] `mark_saved` / `is_saved` / `unmark_saved` work correctly
- [ ] `mark_liked` / `is_liked` / `unmark_liked` work correctly
- [ ] `set_completion` / `get_completion` work correctly
- [ ] `saved_predicate` returns closure including only saved items
- [ ] `liked_predicate` returns closure including only liked items
- [ ] `in_progress_predicate` returns closure matching items with completion in (min, max) exclusive
- [ ] `in_progress` with completion == 0.0 returns false (not started)
- [ ] `in_progress` with completion >= 0.8 returns false (completed)
- [ ] `ComposedFilter` intersects bitmap and predicate results correctly
- [ ] `ComposedFilter::passes()` returns true only when all layers pass
- [ ] Signal dispatch for "save" adds to saved bitmap and marks as seen
- [ ] Signal dispatch for "like" adds to liked bitmap
- [ ] Signal dispatch for "completion" records per-user completion value
- [ ] Signal dispatch for "unsave"/"unlike" removes from respective bitmaps
- [ ] Different users have independent saved/liked/completion state
- [ ] Property test: seen items NEVER pass unseen filter
- [ ] Property test: saved items always pass saved filter; unsaved items never pass
- [ ] Property test: liked items always pass liked filter; unliked items never pass
- [ ] Property test: in_progress only matches items with completion in the configured range
- [ ] Property test: composed filters agree with independent evaluation
- [ ] `cargo clippy -- -D warnings` passes
- [ ] All tests pass

## Research References

- [VISION.md](../../../../VISION.md) -- User-state filters as first-class query primitives
- [USE_CASES.md](../../../../USE_CASES.md) -- Filter reference (Appendix A: unseen, unblocked, saved, liked)
- [API.md](../../../../API.md) -- FILTER clause syntax
- [m2p2 filter.rs](../../../../tidal/src/storage/indexes/filter.rs) -- Existing FilterExpr and FilterResult types

## Implementation Notes

- The `unblocked` filter has a two-stage evaluation because creator_id is not available during the initial filter pass (it is only known per-candidate during scoring). The first stage catches hidden items via the predicate. The second stage catches blocked creators during the candidate evaluation pass in the executor, where `ScoredCandidate::creator_id` is populated. This ensures defense-in-depth: even if the bitmap pass misses something, the candidate-level check catches it.
- The `in_progress` filter uses a `DashMap<(u64, u32), f64>` for completion tracking. This is keyed by (user_id, item_id) and stores the latest completion value. This is simpler than reading from the signal ledger because completion values need to be user-specific (the signal ledger tracks item-level signals, not per-user completion). The completion DashMap is maintained by the signal dispatch for "completion" signals.
- The `InProgressConfig` default thresholds (min: 0.0, max: 0.8) can be overridden in the schema configuration. An item with completion == 0.0 has not started. An item with completion >= 0.8 is considered complete. These thresholds are reasonable for video content (80% completion is "watched").
- The `ComposedFilter` struct is the bridge between bitmap-based metadata filters and predicate-based user-state filters. It provides a uniform `passes(item_id)` interface that the executor uses to filter candidates regardless of whether the filter was evaluated via bitmap or predicate.
- User-state filters and metadata filters compose via the AND implicit in the `FILTER` clause. The executor evaluates them separately (bitmaps vs predicates) but intersects at the candidate level. This is efficient because bitmap evaluation is O(1) per lookup and predicate evaluation is O(1) per call.
- The `saved_predicate` and `liked_predicate` for unknown users return predicates that reject everything. This is correct because an unknown user has saved/liked nothing. The `unseen_predicate` for unknown users accepts everything (unknown user has seen nothing).
- Checkpoint/restore for saved/liked bitmaps uses the same pattern as seen bitmaps: on startup, scan relationship edges of type Save/Like and populate the bitmaps. On shutdown, checkpoint any bitmap state that was not already written as edges. For M3, the saved/liked state is populated from signal dispatch during the session. Full persistence (checkpoint + restore) follows the same pattern as m3p2 Task 03 (hard negatives).
