refactor(discover): both affinities weigh a watch by one rule

The YouTube channel affinity and the Plex one each had the recency decay, the undated-watch rule and
the scale to the strongest; app.draw now holds them. Tests hold that another user's history leans
neither draw, the rail's blind slot, and that a show leans on show recommendations.
This commit is contained in:
2026-11-07 21:48:38 +01:00
parent e7e8d8236e
commit bb91d0a22d
4 changed files with 99 additions and 27 deletions
+24 -4
View File
@@ -5,20 +5,24 @@ A title's chance to come first is its weight over the pool's (Efraimidis–Spira
(`plex.affinity`, `discover.queries`), so a title nobody leans towards still comes up — only less
often. Two things keep it a surprise rather than a ranking:
- **u is the seed's md5 of the row's key**, the feed's shuffle idiom. With every weight 1 the order
is exactly the md5 order the draw had before it was weighted, so a user with no history sees the
old draw, and one seed still moves both sources of the Home hero together. A draw that is not
seeded (the Plex Discover rail) takes u from random().
- **u is the seed's md5 of the row's key**, the Home surprise's seeded order. With every weight 1
the order is exactly that md5 order, so a user with no history sees the unweighted draw, and one
seed still moves both sources of the Home hero together. A draw that is not seeded (the Plex
Discover rail) takes u from random().
- **One slot of three is blind** (`blend`): dealt from the unweighted order, so the same few
favourites cannot fill the rail.
The YouTube surprise draws over a window of the feed scope rather than all of it (`yt_surprise`):
the window is read in SQL, and `shuffled` / `leaning` order it here with the same u.
Both affinities weigh a watch by `recency` and score against the strongest with `scaled`, so the two
sources lean by one rule.
"""
import hashlib
import math
from collections.abc import Callable, Hashable
from datetime import datetime
from typing import Any
from sqlalchemy import BigInteger, Float, cast, func, literal
@@ -29,6 +33,22 @@ from sqlalchemy.sql.elements import ColumnElement
# 2**32: the first 8 hex digits of an md5 as a fraction of one.
_SPAN = 4294967296.0
# A watch with no date (an old import) counts as one this many half-lives ago.
UNDATED_HALF_LIVES = 2
def recency(watched_at: datetime | None, now: datetime, half_life_days: float) -> float:
"""A watch's weight in an affinity: 1 now, half as much every `half_life_days`."""
if watched_at is None:
return 0.5**UNDATED_HALF_LIVES
return 0.5 ** (max(0.0, (now - watched_at).total_seconds() / 86400) / half_life_days)
def scaled(scores: dict[str, float]) -> dict[str, float]:
"""Each score over the strongest, so the strongest is 1; empty when nothing scored."""
top = max(scores.values(), default=0.0)
return {k: v / top for k, v in scores.items()} if top else {}
def seeded_u(
key: ColumnElement[Any] | InstrumentedAttribute[Any], seed: int