# Spec: Briefing UX & Reason Labels ## Problem When tidalDB returns ranked results via `retrieve()` or `search()`, the application receives entity IDs and scores but no structured explanation of **why** each item was surfaced. The `signal_snapshot` field on `ScoredCandidate` is always empty (`vec![]`), and no "reason label" taxonomy exists. Without this, the consuming application cannot: 1. Show users *why* an item appeared in their feed ("From a creator you follow", "Trending in Jazz", "Because you liked similar content") 2. Build trust through transparency -- users who understand why they see something engage more and churn less 3. Debug ranking behavior -- operators cannot tell which signal dominated a result's position 4. Close the feedback loop with reason-aware negative signals ("Not interested *because* of this reason") The p1 milestone ("Concierge Alpha") requires a daily ranked brief where each card carries a human-readable reason label. This feature delivers the engine-side infrastructure for that. ## Solution Add a **reason label** system to tidalDB's query response types. Each ranked result carries one or more `ReasonLabel` values that describe the primary factors that caused the item to appear and rank where it did. Reasons are derived deterministically from the scoring pipeline -- not guessed after the fact. ### Reason Taxonomy A closed enum of reason codes, each with a structured payload: | Reason Code | Description | Example Label | |---|---|---| | `FollowedCreator` | Item is from a creator the user follows | "From Jazz Academy" | | `TrendingGlobal` | Item is in the global trending set | "Trending now" | | `TrendingInCategory` | Item is trending within a filtered category | "Trending in Jazz" | | `TrendingInCohort` | Item is trending within the user's cohort | "Popular with listeners like you" | | `PreferenceMatch` | Item's embedding is close to user's preference vector | "Matches your interests" | | `SocialProof` | People the user follows engaged with this item | "Liked by people you follow" | | `HighQuality` | Item has high completion rate and engagement ratio | "Highly rated" | | `HiddenGem` | Item scored via the hidden_gems profile | "Hidden gem" | | `NewFromFollowed` | Recent item from a followed creator | "New from Jazz Academy" | | `Rising` | Item is overperforming its baseline | "Rising" | | `Controversial` | Item scored via the controversial profile | "Generating discussion" | | `ExplorationBudget` | Item was injected via the exploration mechanism | "Something new to try" | | `TopInWindow` | Item ranked highly in a windowed top sort | "Top this week" | | `SemanticMatch` | Item matched via ANN/embedding similarity | "Similar to content you enjoy" | | `TextRelevance` | Item matched via BM25 text search | "Matches your search" | | `CoEngagement` | Item was co-engaged with the seed item | "Viewers also watched" | | `SessionContext` | Item surfaced due to active session/agent signals | "Based on this conversation" | | `CohortPopular` | Item is popular within the user's cohort | "Popular in your area" | | `SavedSearchMatch` | Item matched a user's saved search | "Matches your saved search" | ### Source Exposure Rules Not all internal scoring details should be exposed to end users. The system distinguishes between: - **User-facing reasons**: Reasons suitable for display in the UI (e.g., "From Jazz Academy", "Trending now"). These use natural language templates. - **Operator-facing reasons**: Full signal decomposition including weights, decay values, and pipeline stage. Always available in `QueryStats` or debug mode. The `ReasonLabel` struct carries both: ```rust pub struct ReasonLabel { pub code: ReasonCode, pub context: HashMap, pub weight: f64, } ``` ### Population Rules Reason labels are populated during the scoring pipeline, not post-hoc: 1. **Stage 1 (Candidate Generation)**: Tag candidates with their source strategy (`FollowedCreator`, `TrendingGlobal`, `ExplorationBudget`, `CoEngagement`). 2. **Stage 3 (Signal Scoring)**: Tag with dominant signal contributors (`PreferenceMatch`, `SocialProof`, `HighQuality`, `Rising`, `Controversial`, `HiddenGem`, `TopInWindow`). 3. **Stage 3b (Search Scoring)**: Tag with retrieval method (`TextRelevance`, `SemanticMatch`). 4. **Stage 4 (Diversity)**: No new labels, but diversity-displaced items retain their original reasons. 5. **Assembly**: Select the top 1-3 reasons by weight for each result. Attach to `RetrieveResult` and `SearchResultItem`. ### Dominance Threshold A reason is included only if its contribution exceeds a minimum threshold (default: 10% of the item's total score contribution). This prevents noisy labels like "0.2% from social proof" from appearing. ### API Surface Changes ```rust pub struct RetrieveResult { pub entity_id: EntityId, pub score: f64, pub rank: usize, pub signals: Vec, pub reasons: Vec, // NEW } pub struct SearchResultItem { pub entity_id: EntityId, pub score: f64, pub rank: usize, pub bm25_score: Option, pub semantic_score: Option, pub signals: Vec, pub metadata: Option>, pub reasons: Vec, // NEW } ``` The `reasons` field is always populated (never `None`). When no reason can be determined (e.g., anonymous query with no profile context), the vec is empty. ## Non-Goals - **Natural language template rendering**: The engine provides structured reason codes and context. The application layer renders them into localized strings. tidalDB does not own i18n. - **Reason-based re-ranking**: Reasons are observational, not prescriptive. They do not change the ranking order. - **User-configurable reason visibility**: All reasons are returned; the application decides what to show. tidalDB does not filter reasons based on user preferences. - **A/B testing of reason labels**: The engine always populates reasons. Whether to show them is an application decision. - **Retroactive reason computation**: Reasons are computed live during query execution. There is no stored history of "why was item X shown to user Y on date Z." ## Success Criteria 1. Every `RetrieveResult` and `SearchResultItem` carries a `reasons: Vec` field. 2. For a `for_you` query with `for_user` set, at least 80% of results have at least one non-empty reason label. 3. For a `trending` query, 100% of results carry the `TrendingGlobal` or `TrendingInCategory` reason. 4. For a `following` query, 100% of results carry the `FollowedCreator` reason. 5. For a `search` query, 100% of results carry either `TextRelevance` or `SemanticMatch` (or both). 6. Reason population adds less than 5% overhead to the query pipeline (measured via the existing benchmark suite). 7. The `signal_snapshot` field on `ScoredCandidate` is populated with actual signal values during scoring (fixing the current empty-vec behavior). 8. `ReasonCode` is a closed, exhaustive enum -- no string-based reason codes. 9. Integration tests verify reason labels for each profile type (for_you, trending, following, search, hidden_gems, rising, controversial, related). ## Dependencies - Existing `ScoredCandidate.signal_snapshot` infrastructure (currently unused but structurally present) - Profile registry and `RankingProfile` definitions - `PreferenceVectors` for preference-match detection - `CreatorItemsBitmap` and `UserStateIndex` for followed-creator detection - `CohortSignalLedger` for cohort-trending detection ## Open Questions None -- the taxonomy and architecture are well-constrained by the existing scoring pipeline.