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.
119 lines
4.7 KiB
Python
119 lines
4.7 KiB
Python
"""A weighted draw that stays a surprise: what "Surprise me" deals from.
|
||
|
||
A title's chance to come first is its weight over the pool's (Efraimidis–Spirakis: order by
|
||
-ln(1-u)/w, u uniform per row). The weight is 1 + the title's affinity to what the user watches
|
||
(`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 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
|
||
from sqlalchemy.dialects.postgresql import BIT
|
||
from sqlalchemy.orm import InstrumentedAttribute
|
||
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
|
||
) -> ColumnElement[float]:
|
||
"""A uniform u in [0, 1) per row, from md5(key || seed) — the order `md5(...)` ascending gives."""
|
||
digest = func.md5(func.concat(key, str(seed)))
|
||
head = cast(cast(literal("x") + func.substr(digest, 1, 8), BIT(32)), BigInteger)
|
||
return cast(head, Float) / _SPAN
|
||
|
||
|
||
def weighted_key(u: ColumnElement[float], weight: ColumnElement[float]) -> ColumnElement[float]:
|
||
"""The row's place in a weighted draw, ascending: -ln(1-u)/w. Monotonic in u when every w is 1,
|
||
so the unweighted order is unchanged."""
|
||
return -func.ln(1.0 - u) / weight
|
||
|
||
|
||
def random_u() -> ColumnElement[float]:
|
||
return func.random()
|
||
|
||
|
||
def u_of(key: str, seed: int) -> float:
|
||
"""`seeded_u` in Python: the same u for the same key and seed."""
|
||
return int(hashlib.md5(f"{key}{seed}".encode()).hexdigest()[:8], 16) / _SPAN
|
||
|
||
|
||
def shuffled(items: list, seed: int, key: Callable[[Any], str]) -> list:
|
||
"""`items` in the seed's md5 order — the order `ORDER BY md5(key || seed)` gives."""
|
||
return sorted(items, key=lambda it: hashlib.md5(f"{key(it)}{seed}".encode()).hexdigest())
|
||
|
||
|
||
def leaning(
|
||
items: list, seed: int, key: Callable[[Any], str], weight: Callable[[Any], float]
|
||
) -> list:
|
||
"""`items` in the weighted draw's order (`weighted_key` in Python), ties in the md5 order."""
|
||
order = {key(it): i for i, it in enumerate(shuffled(items, seed, key))}
|
||
return sorted(
|
||
items,
|
||
key=lambda it: (-math.log(1.0 - u_of(key(it), seed)) / weight(it), order[key(it)]),
|
||
)
|
||
|
||
|
||
def blend(
|
||
weighted: list, blind: list, offset: int, n: int, key: Callable[[Any], Hashable] = lambda x: x
|
||
) -> list:
|
||
"""`n` picks: slot i comes from `blind` when (i + offset) % 3 == 0, else from `weighted`, each
|
||
skipping what an earlier slot took (`key` tells two picks apart). A side that runs out leaves the
|
||
rest to the other."""
|
||
out: list = []
|
||
taken: set = set()
|
||
sources = (iter(weighted), iter(blind))
|
||
|
||
def nxt(source):
|
||
for item in source:
|
||
if key(item) not in taken:
|
||
return item
|
||
return None
|
||
|
||
for i in range(n):
|
||
first, second = (sources[1], sources[0]) if (i + offset) % 3 == 0 else sources
|
||
item = nxt(first)
|
||
if item is None:
|
||
item = nxt(second)
|
||
if item is None:
|
||
break
|
||
taken.add(key(item))
|
||
out.append(item)
|
||
return out
|