7.5 KiB
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:
- Show users why an item appeared in their feed ("From a creator you follow", "Trending in Jazz", "Because you liked similar content")
- Build trust through transparency -- users who understand why they see something engage more and churn less
- Debug ranking behavior -- operators cannot tell which signal dominated a result's position
- 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
QueryStatsor debug mode.
The ReasonLabel struct carries both:
pub struct ReasonLabel {
pub code: ReasonCode,
pub context: HashMap<String, String>,
pub weight: f64,
}
Population Rules
Reason labels are populated during the scoring pipeline, not post-hoc:
- Stage 1 (Candidate Generation): Tag candidates with their source strategy (
FollowedCreator,TrendingGlobal,ExplorationBudget,CoEngagement). - Stage 3 (Signal Scoring): Tag with dominant signal contributors (
PreferenceMatch,SocialProof,HighQuality,Rising,Controversial,HiddenGem,TopInWindow). - Stage 3b (Search Scoring): Tag with retrieval method (
TextRelevance,SemanticMatch). - Stage 4 (Diversity): No new labels, but diversity-displaced items retain their original reasons.
- Assembly: Select the top 1-3 reasons by weight for each result. Attach to
RetrieveResultandSearchResultItem.
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
pub struct RetrieveResult {
pub entity_id: EntityId,
pub score: f64,
pub rank: usize,
pub signals: Vec<Signal>,
pub reasons: Vec<ReasonLabel>, // NEW
}
pub struct SearchResultItem {
pub entity_id: EntityId,
pub score: f64,
pub rank: usize,
pub bm25_score: Option<f32>,
pub semantic_score: Option<f32>,
pub signals: Vec<Signal>,
pub metadata: Option<HashMap<String, String>>,
pub reasons: Vec<ReasonLabel>, // 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
- Every
RetrieveResultandSearchResultItemcarries areasons: Vec<ReasonLabel>field. - For a
for_youquery withfor_userset, at least 80% of results have at least one non-empty reason label. - For a
trendingquery, 100% of results carry theTrendingGlobalorTrendingInCategoryreason. - For a
followingquery, 100% of results carry theFollowedCreatorreason. - For a
searchquery, 100% of results carry eitherTextRelevanceorSemanticMatch(or both). - Reason population adds less than 5% overhead to the query pipeline (measured via the existing benchmark suite).
- The
signal_snapshotfield onScoredCandidateis populated with actual signal values during scoring (fixing the current empty-vec behavior). ReasonCodeis a closed, exhaustive enum -- no string-based reason codes.- Integration tests verify reason labels for each profile type (for_you, trending, following, search, hidden_gems, rising, controversial, related).
Dependencies
- Existing
ScoredCandidate.signal_snapshotinfrastructure (currently unused but structurally present) - Profile registry and
RankingProfiledefinitions PreferenceVectorsfor preference-match detectionCreatorItemsBitmapandUserStateIndexfor followed-creator detectionCohortSignalLedgerfor cohort-trending detection
Open Questions
None -- the taxonomy and architecture are well-constrained by the existing scoring pipeline.