# Spec: Feedback Loop UX ## Overview tidalDB already has the backend machinery for processing user feedback signals: `signal_with_context` records like/hide/dislike/block/skip events, updates the `HardNegIndex` for negative signals, updates `UserStateIndex` for seen/hidden/blocked state, adjusts preference vectors for positive engagement, and records to cohort ledgers. The RETRIEVE and SEARCH executors exclude hard negatives and hidden items in Stage 2.5 of the query pipeline. What is missing is a **coherent, single-call feedback API** that a client application can invoke when a user taps mute, hide, like, or dislike on a piece of content — and a **contract guaranteeing that the next query for that user reflects the feedback immediately**, within the same process, without waiting for WAL flush, background sync, or index rebuild. This feature closes that gap by introducing a `FeedbackAction` enum and a `db.submit_feedback()` method that atomically applies all side effects for a feedback action, and by documenting and testing the "immediate reflection" guarantee. ## Problem Statement Today, a client application that wants to implement "hide this item" must: 1. Call `signal_with_context("hide", item_id, 1.0, now, Some(user_id), creator_id)` to record the signal and update the hard-neg bitmap. 2. Optionally call `user_state.add_hide(user_id, item_id)` if it has access to the internal index (it does not — this is `pub(crate)`). 3. Hope that the next `retrieve()` or `search()` call happens after the DashMap write is visible. Problems with this approach: - **No mute concept.** "Mute creator" requires calling `signal_with_context("block", ...)` AND updating `UserStateIndex::add_block_creator`. The second call is `pub(crate)` — not exposed to the public API. - **Signal type confusion.** The client must know that "hide" maps to `HardNegIndex`, "block" maps to `BlockedState`, and "like" maps to preference vector updates. This is implementation leakage. - **No undo.** Undoing a hide requires calling `hard_negatives.remove()` and `user_state.remove_hide()`, both of which are internal. There is no public undo path. - **No reflection guarantee documented.** The in-memory bitmap updates are synchronous within `signal_with_context`, but this is not documented or tested as a contract. ## Goals - Introduce a `FeedbackAction` enum with variants: `Like`, `Unlike`, `Hide`, `Unhide`, `MuteCreator`, `UnmuteCreator`, `Dislike`, `UndoDislike`, `Save`, `Unsave`, `NotInterested`. - Expose `TidalDb::submit_feedback(user_id, entity_id, action, timestamp)` as the single public entry point for all user feedback. - Each action atomically performs all required side effects (signal write, hard-neg update, user-state update, preference vector update) in one call. - Guarantee that any `retrieve()` or `search()` call with `for_user` issued after `submit_feedback()` returns `Ok(())` will reflect the feedback (item excluded, creator items excluded, preference adjusted). - Support undo for all reversible actions (unlike, unhide, unmute, undo dislike, unsave). - Expose `TidalDb::feedback_state(user_id, entity_id) -> FeedbackState` for reading the current feedback state of an item for a user (is it liked? hidden? is the creator muted?), so the UI can render toggle states. - Write integration tests that verify the "immediate next-query reflection" contract for every action. ## Non-Goals - This feature does not add a new HTTP/REST endpoint to `tidal-server`. The server router can expose this via a `POST /feedback` route in a future feature. - This feature does not add rate limiting to feedback actions. The existing session rate limiter and signal backpressure mechanisms remain unchanged. - This feature does not add batch feedback (e.g., "hide all items from this category"). Each call is for one user-entity pair. - This feature does not persist undo history. Undo is modeled as recording the inverse action, not as a revertible log. - This feature does not add animation or debounce logic — that is a client-side concern. - This feature does not change the signal schema or introduce new signal types. It uses the existing "like", "hide", "dislike", "block", "skip" signal types already declared in schemas. ## Behavioral Specification ### FeedbackAction Enum ```rust #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FeedbackAction { Like, Unlike, Hide, Unhide, MuteCreator { creator_id: u64 }, UnmuteCreator { creator_id: u64 }, Dislike, UndoDislike, Save, Unsave, NotInterested, } ``` ### submit_feedback Method ```rust impl TidalDb { pub fn submit_feedback( &self, user_id: u64, entity_id: EntityId, action: FeedbackAction, timestamp: Timestamp, ) -> crate::Result<()>; } ``` ### Action Dispatch Table | Action | Signal Written | Hard-Neg | UserState Update | Preference Vector | |--------|---------------|----------|------------------|-------------------| | `Like` | `"like"` weight=1.0 | -- | `add_like(user, item)` | Positive blend | | `Unlike` | -- | -- | Remove from liked bitmap | -- | | `Hide` | `"hide"` weight=1.0 | `add(user, item)` | `add_hide(user, item)` | -- | | `Unhide` | `"hide"` weight=-1.0 | `remove(user, item)` | `remove_hide(user, item)` | -- | | `MuteCreator` | `"block"` weight=1.0 | -- | `add_block_creator(user, creator)` | -- | | `UnmuteCreator` | -- | -- | `remove_block_creator(user, creator)` | -- | | `Dislike` | `"dislike"` weight=1.0 | `add(user, item)` | `mark_seen(user, item)` | Negative blend (0.3x damping) | | `UndoDislike` | -- | `remove(user, item)` | -- | -- | | `Save` | -- | -- | `add_save(user, item)` | -- | | `Unsave` | -- | -- | Remove from saved bitmap | -- | | `NotInterested` | `"skip"` weight=1.0 | -- (unless 3+ skips) | `mark_seen(user, item)` | Mild negative (0.1x damping) | ### FeedbackState ```rust #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct FeedbackState { pub is_liked: bool, pub is_hidden: bool, pub is_disliked: bool, pub is_saved: bool, pub is_creator_muted: bool, } ``` ### Immediate Reflection Guarantee After `submit_feedback` returns `Ok(())`, any subsequent `retrieve()` or `search()` call with `for_user` set to the same `user_id` will reflect the feedback. This works because DashMap insertions are synchronous and the query pipeline reads from the same DashMap instances. ## Acceptance Criteria 1. Like/Unlike roundtrip toggles liked bitmap correctly. 2. Hide/Unhide roundtrip toggles hard-neg and hidden-items correctly. 3. MuteCreator/UnmuteCreator roundtrip toggles blocked-creators correctly. 4. Dislike/UndoDislike roundtrip toggles hard-neg correctly. 5. Save/Unsave roundtrip toggles saved bitmap correctly. 6. 3 NotInterested actions escalate to hard negative. 7. feedback_state returns correct booleans for all states. 8. All signal-writing actions are WAL-durable. 9. Undo actions on untouched items are no-ops (no panic). 10. 21 unit tests and 10 integration tests pass. 11. cargo fmt and cargo clippy clean (no warnings from feedback module). 12. Full lib test suite (1345 tests) passes with no regressions.