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:
+24
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user