tidaldb/.sdlc/features/p1-briefing-ux-reason-labels/spec.md

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:

  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:

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:

  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

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

  1. Every RetrieveResult and SearchResultItem carries a reasons: Vec<ReasonLabel> 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.