Skip to content

Recommendation and Ranking

Aswin C edited this page Oct 15, 2026 · 3 revisions

Recommendation & Ranking

Recommendations are what set boxlore apart. They are hybrid: boxlore's private API finds candidate shows and episodes on the server, then the phone re-ranks them for your listening habits. Neither stage works alone.

The repository's recommendation guide is the detailed reference. This page summarises it and links each idea to the code.


Two stages

  1. Retrieval, on the server The app sends a bounded set of listening seeds to the API. The API uses semantic matching against the podcast catalog to return candidate shows and episodes. The API's code is private and is not in this repository. The recommendation guide describes it as stateless, with no cloud user-profile store.
  2. Re-ranking, on the phone :core:ranking scores the candidates with a LinUCB-style contextual bandit and a set of taste meters. Both learn from what you do and are stored on the phone in adaptive_ranking_database.
flowchart TB
    subgraph Phone_Seeds ["On the phone: build the request"]
        S1["Recent listening seeds<br/>(liked, completed, partly played)"]
        S2["Subscribed show IDs"]
        S3["Recently played episode IDs to exclude"]
    end

    subgraph Server ["On the server: boxlore API (private)"]
        R1["Semantic matching against the catalog"]
        R2["Curated daypart rows, trending, search"]
    end

    subgraph Phone_Rank ["On the phone: re-rank"]
        F1["18-slot feature vector per candidate"]
        M1["LinUCB-style score blended with the API's prior"]
        D1["Diversity: cap repeats per show, genre penalties"]
    end

    Phone_Seeds --> Server
    Server --> Phone_Rank
    Phone_Rank --> UI["Home, Explore, Smart Queue"]
Loading

Stage 1: what the app sends

The main recommendation call is POST recommendations/v2 in BoxLoreApi. The request (SyncModels, built in PodcastRepositoryRecommendations) carries:

Field Limit What it is
Seeds Up to 12 Recent episodes and shows, each with title, genre and description text. Episode seeds are weighted: liked 1.0, completed 0.9, at least 50% played 0.75, at least 20% played 0.55, otherwise 0.35. Show seeds are weighted 0.35.
Subscribed show IDs Up to 250 So the API can tell new shows from ones you already follow
Excluded episode IDs Up to 250 Recently played episodes, so they aren't suggested again
Excluded show IDs Shows to leave out of the results
Interests Up to 12 Topic hints, when available
Context Country, content languages, mode and result limit

Requests also carry the app key and a device ID header. Other surfaces use their own endpoints, for example POST recommendations/because-you-like, POST episodes/similar, GET search/semantic, GET curated/vibe (Home daypart rows) and GET curated/curiosity-v3 (Lore). What we track and why explains what leaves the phone.


Stage 2: re-ranking on the phone

Features

AdaptiveCandidateScorer builds an 18-slot feature vector (FeatureSlot in RankingModels) for each candidate:

INTERCEPT, SHOW_AFFINITY, GENRE_AFFINITY, SOURCE_AFFINITY, FRESHNESS, NOVELTY, DURATION_FIT, SUBSCRIBED, RESUME_PROGRESS, UNPLAYED, SERIAL_MATCH, SERVER_RELEVANCE, EXPOSURE_FATIGUE, TIME_CONTEXT, OFFLINE_SUITABILITY, EXPLICIT_PREFERENCE, RECENT_SUBSCRIPTION, CURRENT_SHOW.

Two examples:

  • Freshness decays with episode age: $\exp(-\text{ageHours} / 336)$, a two-week time constant.
  • Duration fit falls off linearly around a 45-minute ideal.

The schema is versioned. A stored model with the wrong dimension is not loaded.

The learned model: AdaptiveLinearModel

Each objective keeps its own regularised online linear model with optional upper-confidence exploration.

State Meaning
$A$ (covariance) $d \times d$, starts as the identity (ridge 1.0) and accumulates $x x^T$
$A^{-1}$ (inverseCovariance) Recomputed with Gauss-Jordan after every update
$b$ (rewardVector) Accumulates $x \cdot r$
updateCount Resolved outcomes so far

Learned weights are $\theta = A^{-1} b$. Scoring:

$$\text{learned} = \tanh(\theta^T x)$$ $$\text{blend} = \min\left(\frac{n}{50}, 1\right) \cdot 0.65$$ $$\text{score} = \text{clamp}\left((1 - \text{blend}) \cdot \text{prior} + \text{blend} \cdot \text{learned} + \alpha\sqrt{x^T A^{-1} x},\ -1,\ 1\right)$$

  • $\alpha = 0.15$. The exploration bonus applies only when the objective allows exploration and $n \ge 50$.
  • The prior is the API's relevance score or the app's own deterministic score. It always keeps at least 35% of the weight.

Updates apply a forgetting factor so tastes can drift:

$$A \leftarrow 0.995 \cdot A + 0.005 \cdot I + x x^T \qquad b \leftarrow 0.995 \cdot b + r x$$

Objectives and where re-ranking is on

Objective Exploration Typical use
DISCOVERY After 50 outcomes Home recommendations, Explore, Lore
CONTINUATION Limited Daily Mix (MixtapeEngine), Smart Queue
YOUR_SHOWS None Your shows; a deterministic score that never uses the bandit
OFFLINE None Downloads

RankingRolloutPolicy turns learned re-ranking on for Home by default. Other surfaces keep the API's order or the app's deterministic scores unless re-ranking is enabled for them. Curated Home daypart rows always keep the API's order.


Taste meters

BayesianPreferenceFacet stores evidence for and against a facet and turns it into an affinity between −1 and 1 using a Beta-style prior.

  • Facet types: SHOW, GENRE, SOURCE, DURATION_BUCKET, TIME_CONTEXT, INTENT. Feedback currently updates SHOW, GENRE and SOURCE.
  • Evidence decays with a 90-day half-life: $S(t) = S_0 \cdot 2^{-t / 90\text{ days}}$. A genre you stop listening to drifts back toward neutral.
  • Genre keys are canonicalised, and the placeholder genre "Podcast" is ignored.

Taste meters also feed the bandit through the affinity features.


Rewards

When a surface shows a candidate, the engine records a RankingExposureEntity with the feature snapshot. When you act on it, RankingReward turns the action into a reward and the exposure resolves into a model update. If there was no exposure, for example after a deep link, the taste meters still update.

Action Weight
Subscribe +0.80
Like +0.65
Add to queue +0.55
Download manually +0.55
Complete +0.35
Move up in queue +0.25
Meaningful play +0.22
Open details +0.08
Move down in queue −0.25
Unlike −0.50
Unsubscribe −0.70
Early skip −0.70
Dismiss −0.75
Remove an autofilled queue item −0.80

Listening time adds up to +0.4 more, based on minutes listened and the fraction of the episode played. The total is clamped to −1 to 1.

  • Meaningful play: at least 60 seconds, or at least 20% of the episode.
  • Early skip: skipping within 30 seconds, counted only for items the queue added itself or that came from Lore.
  • Complete: at least 95% played, the last 5 seconds, or no more than 5 minutes left on an episode of 15 minutes or more.

Diversity

DiversityReranker shapes the final list:

  • Removes duplicate episodes.
  • Caps repeats per show: maxPerShow is 2 by default and 1 for Home's trending grid.
  • Subtracts 0.08 per repeat of a genre and 0.12 for a recently shown show.
  • Can reserve one slot for something new.

Cold start

There is no separate cold-start mode. With no outcomes the blend weight is zero, so the API's prior decides the order. The learned weight then grows linearly to 65% by 50 outcomes, and exploration starts only after that. Diagnostics label the stages cold_start (no outcomes), learning (fewer than 50) and adaptive.


Storage, backup and reset

  • Models, taste meters and exposures live in adaptive_ranking_database, owned by :core:ranking. Exposures are pruned by age and row count.
  • The library backup includes ranking state (adaptiveRanking). See Data & Storage Contracts.
  • Settings > Privacy > Reset recommendations clears the local ranking tables.