From 25aed6635ff056d97464309fb3aff12293ec89af Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 7 Nov 2026 04:00:00 +0100 Subject: [PATCH] perf(feed): a shuffle walks a stored random key instead of sorting the whole scope Every video carries a random number drawn once (videos.shuffle_key, migration 0118, indexed with id). A seed picks a point on that ring: the feed's Shuffle sort pages along it and wraps, and the Home surprise reads a 500-video window of it and shuffles that window by the seed, so no video is favoured for sitting after a wide gap. The md5(id || seed) order read and sorted the whole feed scope on every call: ~350 ms on prod per surprise, 381-767 ms per Shuffle page on localdev, now 14 ms and 4 ms. --- backend/.basedpyright/baseline.json | 112 ------------- .../versions/0118_videos_shuffle_key.py | 40 +++++ backend/app/catalog/models.py | 4 + backend/app/discover/queries.py | 112 ++++++------- backend/app/draw.py | 26 +++ backend/app/feedquery.py | 68 +++++++- backend/tests/test_shuffle_walk.py | 151 ++++++++++++++++++ 7 files changed, 328 insertions(+), 185 deletions(-) create mode 100644 backend/alembic/versions/0118_videos_shuffle_key.py create mode 100644 backend/tests/test_shuffle_walk.py diff --git a/backend/.basedpyright/baseline.json b/backend/.basedpyright/baseline.json index af71727c..75d496cf 100644 --- a/backend/.basedpyright/baseline.json +++ b/backend/.basedpyright/baseline.json @@ -185,118 +185,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 50, - "endColumn": 56, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 50, - "endColumn": 56, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 50, - "endColumn": 56, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 50, - "endColumn": 56, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 50, - "endColumn": 56, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 50, - "endColumn": 56, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 50, - "endColumn": 56, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 50, - "endColumn": 56, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 50, - "endColumn": 56, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 50, - "endColumn": 56, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 50, - "endColumn": 56, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 50, - "endColumn": 56, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 50, - "endColumn": 56, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 50, - "endColumn": 56, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { diff --git a/backend/alembic/versions/0118_videos_shuffle_key.py b/backend/alembic/versions/0118_videos_shuffle_key.py new file mode 100644 index 00000000..683b5a76 --- /dev/null +++ b/backend/alembic/versions/0118_videos_shuffle_key.py @@ -0,0 +1,40 @@ +"""videos: a random key per video, so a seeded shuffle walks an index instead of sorting the scope + +Revision ID: 0118_videos_shuffle_key +Revises: 0117_videos_length_sort_indexes +Create Date: 2026-11-07 + +The feed's Shuffle sort and the Home surprise ordered the whole feed scope by md5(id || seed) on every +call: ~350 ms on prod for a scope of ~213k videos, and a Shuffle page deeper in the list cost more +(767 ms on page 7 on localdev, against 4 ms for Newest). Each video now carries a random number drawn +once (`Video.shuffle_key`); a seed picks a point on that ring and the draw reads the index from there +(`feedquery.shuffle_walk`). + +One ADD COLUMN with a volatile default rewrites the table and fills every row with its own value. On +localdev (278k videos) that took 28 s, against 84 s for an UPDATE of a nullable column, so the rewrite +it is: the table is locked for that time, and a deploy has the app down while it migrates anyway. +The index is (shuffle_key, id): the walk's keyset cursor compares the pair. +""" + +from alembic import op + +revision: str = "0118_videos_shuffle_key" +down_revision: str | None = "0117_videos_length_sort_indexes" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE videos ADD COLUMN IF NOT EXISTS shuffle_key double precision " + "NOT NULL DEFAULT random()" + ) + with op.get_context().autocommit_block(): + op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_shuffle_key") + op.execute("CREATE INDEX CONCURRENTLY ix_videos_shuffle_key ON videos (shuffle_key, id)") + + +def downgrade() -> None: + with op.get_context().autocommit_block(): + op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_shuffle_key") + op.execute("ALTER TABLE videos DROP COLUMN IF EXISTS shuffle_key") diff --git a/backend/app/catalog/models.py b/backend/app/catalog/models.py index a55a9e84..daf44bcb 100644 --- a/backend/app/catalog/models.py +++ b/backend/app/catalog/models.py @@ -10,6 +10,7 @@ from sqlalchemy import ( Boolean, Computed, DateTime, + Float, ForeignKey, Integer, JSON, @@ -217,6 +218,9 @@ class Video(Base, TimestampMixin): last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) unavailable_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) unavailable_reason: Mapped[str | None] = mapped_column(String(24)) + # A random number in [0, 1), drawn once when the row is written: the ring a seeded shuffle walks + # (`feedquery.shuffle_walk`, index ix_videos_shuffle_key on (shuffle_key, id), migration 0118). + shuffle_key: Mapped[float] = mapped_column(Float, nullable=False, server_default=func.random()) @hybrid_property def length_seconds(self) -> int | None: diff --git a/backend/app/discover/queries.py b/backend/app/discover/queries.py index 26a4cadb..f83e6498 100644 --- a/backend/app/discover/queries.py +++ b/backend/app/discover/queries.py @@ -10,29 +10,22 @@ explore-visible and in-progress for free, and cannot drift from them. The Plex h narrows EVERY rail by the Plex sidebar's `LibraryFilters`, which is right for a surface that HAS a sidebar and wrong for a landing page that does not. -**One seed drives both halves.** The feed orders a shuffle by `md5(id || seed)` so a keyset cursor -stays valid across pages; the Plex surprise rail uses `ORDER BY random()`, which is fine for a single -un-paged draw but cannot be reproduced. A cross-source reroll needs both halves to move together — -otherwise "Mást kérek" re-draws the Plex picks while the YouTube ones stand still — so the Plex side -here takes the feed's seeded-md5 idiom instead of `random()`. +**One seed drives both halves.** The YouTube half starts on the ring of stored random keys at the +seed's point and shuffles that window by `md5(id || seed)`; the Plex surprise rail uses +`ORDER BY random()`, which is fine for a single un-paged draw but cannot be reproduced. A +cross-source reroll needs both halves to move together — otherwise "Mást kérek" re-draws the Plex +picks while the YouTube ones stand still — so the Plex side here orders by the same seeded md5 +instead of `random()`. """ -import json from datetime import datetime, timezone from sqlalchemy import ( - Float, - Select, and_, - cast, func, - literal, - literal_column, or_, select, - union_all, ) -from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Session, aliased from app import draw @@ -43,6 +36,7 @@ from app.feedquery import ( not_blocked_cond, order_by, serialize, + shuffle_walk, sort_keys, ) from app.models import User @@ -128,66 +122,50 @@ def yt_surprise(db: Session, user: User, budget: str, seed: int, limit: int) -> Shorts are opened up only for the shortest band: "I have ten minutes" is the one answer a Short is actually a good response to, and the feed's own default keeps them out otherwise.""" lo, hi = band(budget) - params = dict(_FEED_BASE, min_duration=lo, max_duration=hi, include_shorts=budget == "short") - query, rank_expr = filtered_query(db, user, **params) - keys = sort_keys("shuffle", seed, rank_expr) - - # Over-fetch, then drop anything already started. The feed's `unwatched` scope keeps watched and - # hidden out but deliberately keeps in-progress rows in (they belong in the feed). Here they must - # go: the resume tier has already had its chance at them, and it may have passed precisely because - # the REMAINING time did not fit the budget — offering the same item back as a surprise, whose - # runtime does fit, would label a half-finished thing "Meglepetés" and contradict the question. - take = limit * 4 - - def unstarted(rows) -> list[dict]: - return [r for r in map(serialize, rows) if not r["position_seconds"]] + params: dict = { + **_FEED_BASE, + "min_duration": lo, + "max_duration": hi, + "include_shorts": budget == "short", + } + query, _rank = filtered_query(db, user, **params) + # A window of the ring, not the whole scope: the seed's SURPRISE_WINDOW videos read along the + # stored random key (`shuffle_walk`, a millisecond), then shuffled by the seed. Dealing straight + # from the ring would favour for good every video that sits after a wide gap of it (the first + # video past a random point is the one whose gap the point fell into); shuffling the window makes + # each video's chance the window's share, even to within a few percent. A scope no larger than + # the window is the whole scope, in exactly the md5 order the draw always had. + # + # Anything already started is dropped. The feed's `unwatched` scope keeps watched and hidden out + # but deliberately keeps in-progress rows in (they belong in the feed). Here they must go: the + # resume tier has already had its chance at them, and it may have passed precisely because the + # REMAINING time did not fit the budget — offering the same item back as a surprise, whose runtime + # does fit, would label a half-finished thing "Meglepetés" and contradict the question. + window = [ + r + for r in map(serialize, shuffle_walk(db, query, seed, SURPRISE_WINDOW)) + if not r["position_seconds"] + ] + blind = draw.shuffled(window, seed, key=_video_id) channels = _channel_affinity(db, user) if not channels: - return unstarted(db.execute(query.order_by(*order_by(keys)).limit(take)).all())[:limit] + return blind[:limit] - # Both orders from ONE pass over the scope. The pool is the whole feed scope (~200k videos on a - # prod-sized catalog, ~390 ms a pass), so a second ordered query for the weighted draw doubled - # the cost; a materialized pool of (id, shuffle key, weighted key) is read twice instead. - weight = literal_column("1.0") + CHANNEL_WEIGHT * func.coalesce( - cast(cast(literal(json.dumps(channels)), JSONB).op("->>")(Video.channel_id), Float), 0.0 - ) - shuffle = func.md5(func.concat(Video.id, str(seed))) - pool = ( - query.with_only_columns( - Video.id.label("vid"), - shuffle.label("plain"), - draw.weighted_key(draw.seeded_u(Video.id, seed), weight).label("leaning"), - ) - .cte("surprise_pool") - .prefix_with("MATERIALIZED") - ) + def weight(row: dict) -> float: + return 1.0 + CHANNEL_WEIGHT * channels.get(row["channel_id"], 0.0) - def top(side: int, *order) -> Select: - part = ( - select(pool.c.vid, pool.c.plain, pool.c.leaning, literal(side).label("side")) - .order_by(*order, pool.c.vid) - .limit(take) - .subquery() - ) - return select(part) + leaning = draw.leaning(window, seed, key=_video_id, weight=weight) + return draw.blend(leaning, blind, seed, limit, key=_video_id) - # One statement, so the pool is built once; each side's order is restored from its keys. - got = db.execute(union_all(top(0, pool.c.plain), top(1, pool.c.leaning, pool.c.plain))).all() - plain_ids = [ - r.vid for r in sorted((r for r in got if r.side == 0), key=lambda r: (r.plain, r.vid)) - ] - leaning_ids = [ - r.vid - for r in sorted((r for r in got if r.side == 1), key=lambda r: (r.leaning, r.plain, r.vid)) - ] - rows = { - r["id"]: r - for r in unstarted(db.execute(query.where(Video.id.in_({*plain_ids, *leaning_ids}))).all()) - } - blind = [rows[i] for i in plain_ids if i in rows] - leaning = [rows[i] for i in leaning_ids if i in rows] - return draw.blend(leaning, blind, seed, limit, key=lambda r: r["id"]) + +# How many videos of the ring a surprise draws from: wide enough that a channel the user watches has +# a few in it, small enough to read in a millisecond or two. +SURPRISE_WINDOW = 500 + + +def _video_id(row: dict) -> str: + return row["id"] # How much a channel the user watches most outweighs one they never watch (1 + this), and how far back diff --git a/backend/app/draw.py b/backend/app/draw.py index 25ce8747..7281501d 100644 --- a/backend/app/draw.py +++ b/backend/app/draw.py @@ -11,8 +11,13 @@ often. Two things keep it a surprise rather than a ranking: 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. """ +import hashlib +import math from collections.abc import Callable, Hashable from typing import Any @@ -44,6 +49,27 @@ 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: diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py index 173c806e..5fec78f6 100644 --- a/backend/app/feedquery.py +++ b/backend/app/feedquery.py @@ -13,7 +13,19 @@ import binascii import json from datetime import date, datetime, timedelta, timezone -from sqlalchemy import BigInteger, Select, and_, case, cast, false, func, or_, select +from sqlalchemy import ( + BigInteger, + Select, + and_, + case, + cast, + false, + func, + literal, + or_, + select, + tuple_, +) from sqlalchemy.orm import Session, aliased from app.collation import collated @@ -536,15 +548,17 @@ def sort_keys(sort: str, seed: int, rank_expr=None, lang: str | None = None) -> }, pub, ], - # Deterministic per-seed shuffle: the same seed yields the same total order, so - # the cursor stays valid across pages of one shuffled run. + # The ring of stored random keys from the seed's point (`shuffle_walk`): the keys at or after + # it, then the ones before. The same seed yields the same total order, so the cursor stays + # valid across pages; `feed_page` reads it as two index ranges, never as this sort. "shuffle": [ { - "expr": func.md5(func.concat(Video.id, str(seed))), + "expr": case((Video.shuffle_key < shuffle_start(seed), 1), else_=0), "asc": True, "nullable": False, - "kind": "str", - } + "kind": "num", + }, + {"expr": Video.shuffle_key, "asc": True, "nullable": False, "kind": "num"}, ], } # Relevance (search-result ranking) only exists when there's a query to rank against; @@ -700,6 +714,40 @@ class BadCursor(ValueError): """A `cursor` this module did not write (or one from another sort): the caller's bad input.""" +# The golden ratio's fraction: consecutive seeds land far apart on the ring, never next to each other. +_GOLDEN = 0.6180339887498949 + + +def shuffle_start(seed: int) -> float: + """The point in [0, 1) where a seed's shuffle starts on the ring of `Video.shuffle_key`.""" + return (seed * _GOLDEN) % 1.0 + + +def shuffle_walk( + db: Session, query: Select, seed: int, n: int, after: tuple[int, float, str] | None = None +) -> list: + """Up to `n` rows of `query` in the seed's ring order: shuffle_key from the seed's point upwards, + then from 0 up to that point. `after` is a cursor's (phase, key, id): the walk resumes past it. + + Each arc is an ascending range read along ix_videos_shuffle_key with the scope's filters checked + row by row, so a draw costs the rows it reads, not a sort of the scope — the md5(id || seed) order + it replaced read and sorted every row the filters let through (~350 ms on prod for ~213k).""" + start = shuffle_start(seed) + phase_from, key, last_id = after if after is not None else (0, None, None) + rows: list = [] + for phase in (0, 1): + if phase < phase_from or len(rows) >= n: + continue + arc = Video.shuffle_key >= start if phase == 0 else Video.shuffle_key < start + q = query.where(arc) + if key is not None and phase == phase_from: + q = q.where( + tuple_(Video.shuffle_key, Video.id) > tuple_(literal(key), literal(last_id)) + ) + rows += db.execute(q.order_by(Video.shuffle_key, Video.id).limit(n - len(rows))).all() + return rows + + def feed_page( db: Session, user: User, @@ -734,6 +782,14 @@ def feed_page( k["value"] = v if ranked and window is not None: rows = windowed_relevance_rows(db, query, keys, window, cursor_id, limit + 1) + elif sort == "shuffle": + after = None + if vals is not None and cursor_id is not None: + try: + after = (int(vals[0]), float(vals[1]), str(cursor_id)) + except (TypeError, ValueError) as e: + raise BadCursor("Invalid feed cursor") from e + rows = shuffle_walk(db, query, seed, limit + 1, after) else: query = query.order_by(*order_by(keys)) if cursor_id is not None: diff --git a/backend/tests/test_shuffle_walk.py b/backend/tests/test_shuffle_walk.py new file mode 100644 index 00000000..6c2b70a1 --- /dev/null +++ b/backend/tests/test_shuffle_walk.py @@ -0,0 +1,151 @@ +"""A seeded shuffle walks a stored random key instead of sorting the whole scope. DB lane. + +Every video carries a random number drawn once (`Video.shuffle_key`). A seed picks a point on that +ring; the feed's Shuffle sort pages along the ring from there and wraps, and the Home surprise reads +a window of it and shuffles that window by the seed (`discover.queries.yt_surprise`). Ordering the +scope by md5(id || seed) cost ~350 ms per call on prod and grew with every Shuffle page. +""" + +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone + +from sqlalchemy import event + +from app import feedquery +from app.catalog.models import Channel, Subscription, Video +from app.discover import queries +from app.discover.queries import _FEED_BASE + +NOW = datetime.now(timezone.utc) +KEYS = {"k05": 0.05, "k20": 0.20, "k40": 0.40, "k60": 0.60, "k80": 0.80, "k95": 0.95} + + +def _seed(db, make_user, keys=KEYS): + user = make_user() + db.add(Channel(id="UCring", title="Ring")) + db.flush() + db.add(Subscription(user_id=user.id, channel_id="UCring")) + db.add_all( + [ + Video( + id=vid, + channel_id="UCring", + title=vid, + duration_seconds=20 * 60, + published_at=NOW - timedelta(days=i + 1), + shuffle_key=key, + ) + for i, (vid, key) in enumerate(keys.items()) + ] + ) + db.commit() + return user + + +def _ring(seed, keys=KEYS): + """The ring order from the seed's point: the keys at or after it ascending, then the rest.""" + r = feedquery.shuffle_start(seed) + return sorted(keys, key=lambda v: (keys[v] < r, keys[v])) + + +def _seed_landing_between(lo, hi): + return next(s for s in range(1000) if lo < feedquery.shuffle_start(s) < hi) + + +def test_the_walk_starts_at_the_seeds_point_and_wraps(db, make_user): + user = _seed(db, make_user) + query, _ = feedquery.filtered_query(db, user, **_FEED_BASE) + seed = _seed_landing_between(0.45, 0.55) # starts at k60, wraps to k05 after k95 + got = [r.id for r in feedquery.shuffle_walk(db, query, seed, 6)] + assert got == ["k60", "k80", "k95", "k05", "k20", "k40"] + assert [r.id for r in feedquery.shuffle_walk(db, query, seed, 4)] == got[:4] + + +def test_the_feed_shuffle_pages_along_the_ring(db, make_user, as_user): + user = _seed(db, make_user) + client = as_user(user) + for seed in (_seed_landing_between(0.1, 0.15), _seed_landing_between(0.85, 0.9)): + walked, cursor = [], None + for _ in range(5): + url = f"/api/feed?sort=shuffle&seed={seed}&limit=2" + ( + f"&cursor={cursor}" if cursor else "" + ) + body = client.get(url).json() + walked += [it["id"] for it in body["items"]] + cursor = body["next_cursor"] + if not body["has_more"]: + break + assert walked == _ring(seed), seed + + +def test_a_new_seed_opens_the_shuffle_elsewhere(db, make_user, as_user): + user = _seed(db, make_user) + client = as_user(user) + first = { + seed: client.get(f"/api/feed?sort=shuffle&seed={seed}&limit=2").json()["items"][0]["id"] + for seed in (_seed_landing_between(0.1, 0.15), _seed_landing_between(0.65, 0.7)) + } + assert sorted(first.values()) == ["k20", "k80"] + + +@contextmanager +def _statements(db): + seen: list[str] = [] + + def grab(conn, cursor, statement, *args): + seen.append(statement) + + engine = db.get_bind() + event.listen(engine, "before_cursor_execute", grab) + try: + yield seen + finally: + event.remove(engine, "before_cursor_execute", grab) + + +def _draws_of_videos(seen): + return [s for s in seen if "FROM videos" in s and "LIMIT" in s] + + +def test_neither_draw_orders_the_scope_by_a_hash(db, make_user): + # Each draw reads the videos as an ascending range of the key (what ix_videos_shuffle_key serves), + # never ordered by a hash or by the ring's CASE (which no index can serve). + user = _seed(db, make_user) + with _statements(db) as surprise: + queries.yt_surprise(db, user, "any", 7, 3) + with _statements(db) as page: + feedquery.feed_page( + db, user, dict(_FEED_BASE), sort="shuffle", seed=7, limit=2, cursor=None, lang="en" + ) + for seen in (surprise, page): + draws = _draws_of_videos(seen) + assert draws + for s in draws: + assert "md5(" not in s.lower() + assert "ORDER BY videos.shuffle_key, videos.id" in s.replace("\n", " "), s + + +def test_the_surprise_reads_a_window_of_the_ring(db, make_user, monkeypatch): + user = _seed(db, make_user) + monkeypatch.setattr(queries, "SURPRISE_WINDOW", 3) + for seed in range(20): + drawn = {r["id"] for r in queries.yt_surprise(db, user, "any", seed, 3)} + assert drawn == set(_ring(seed)[:3]), seed + + +def test_a_wide_gap_on_the_ring_does_not_make_a_favourite(db, make_user): + # One video after an empty half of the ring, twenty packed into the other half. Taking the first + # video after the seed's point would deal the lonely one about half the time; the window is + # shuffled by the seed, so it leads about as often as any other. + keys = {"lonely": 0.5} | {f"p{i:02}": 0.51 + i * 0.02 for i in range(20)} + user = _seed(db, make_user, keys) + leads = [queries.yt_surprise(db, user, "any", s, 1)[0]["id"] for s in range(90)] + assert leads.count("lonely") < 15, leads.count("lonely") + + +def test_a_new_video_gets_its_own_key(db, make_user): + _seed(db, make_user, {}) + db.add_all([Video(id=f"n{i}", channel_id="UCring", title="n") for i in range(3)]) + db.commit() + keys = [db.get(Video, f"n{i}").shuffle_key for i in range(3)] + assert len(set(keys)) == 3 and all(0 <= k < 1 for k in keys)