Shipped on branch wt/recs, 2026-09-20. The engine is pure and thoroughly tested in src/recs/; the nightly jobs that run it are covered in Nightly jobs.

What each button means

Dwell is meant to measure active watching only — a paused or backgrounded tab should not accrue duration. watch_history.duration_seconds (supabase/migrations/20260920090000_recommendations.sql) is the column that carries it; the taste module (src/recs/taste.ts) already computes the dwell factor from it, defaulting to a neutral 1.0 for any row where it is still null.

The taste vector

src/recs/taste.ts folds every one of a user’s signals into one Taste vector — the same Probabilities shape the taxonomy vectors use — by weighting each signal (heart/like/dislike undecayed, watch decayed by age with a 30-day half-life) and summing into the channel’s category vector.

Channel similarity

src/recs/similar.ts computes, for every channel, its top 24 nearest neighbors by cosine similarity over vectorFor() (the Jev-classified vector when one exists, tags-only otherwise), with a small language boost and a small popularity prior mixed in. scripts/build-channel-similar.ts runs this nightly for the whole catalog and upserts the result into channel_similar.

Candidates → prescore → Jev blend

src/recs/candidates.ts builds a pool of channel candidates per user (seeded by taste categories, saved/liked channels, and trending); src/recs/blend.ts turns that pool into ranked RecRows. In full mode, each candidate’s code-only prescore is blended with a Noul score returned by one Jev call per user (src/recs/questions.ts shapes that request as a viewer state block — ranked category names, channel names, languages, words only, never numbers — plus one question per candidate). A candidate missing from the returned noulScores map falls back to prescore-only for that one channel, even inside an otherwise-full run.

Row lineup (Watch screen)

  1. Top picks for youuser_recs row top-picks, signed-in users with 3 or more signals
  2. Continue watching
  3. Your channels
  4. Because you liked/saved user_recs row because:<channel_id>, up to 2 seeds (most-recent heart, else like)
  5. You might likechannel_similar neighbors, filtered against reactions and hidden channels
  6. Trending on Bytestream — works for anonymous viewers too
  7. Catalog shelves

The degradation ladder

Jev is a bonus, never a dependency.
1

full

A TYPESAFE_API_KEY is set and Jev answers: prescore blended with the per-user Noul score.
2

vectors-only

No key, no credits, or a Jev call exhausted its retries: falls back to the channel_paths.probabilities cosine vectors already in the database — no extra call needed.
3

tags-only

No classification row exists for a channel at all: falls back to a one-hot vector rolled up from the channel’s publisher tags to their parent taxonomy keys.
The run always exits 0. user_recs.reason records which mode built each row for later inspection, but the mode is never surfaced in the UI — a viewer never sees which rung of the ladder their picks came from.
A 402/403 from Jev, or one user’s call exhausting its retries, flips a run-level demoted flag: that user and every user processed after them in the same run score prescore-only for the rest of the run. Rows already blended before the flag flipped are kept as-is.

What’s next

Phase 2 (designed, deferred): an “On now for you” row, programme-aware Jev scoring, and dayparting. Translation was explicitly considered and rejected — the taxonomy vectors are already language-agnostic, so there’s nothing for a translation layer to fix.