From e3139e8712a043950f5469b09d6201b69d651fa3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:14:03 -0400 Subject: [PATCH] [feed] Break up per-creator clustering in For You / Trending grids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The For You (personalized) and Trending tabs ranked strictly by tier/score and view count, which stacked a creator's whole back-catalogue into one block. Add spreadAuthors() to the feed sorters: a gentle diversity re-rank that keeps the caller's ranked order as the priority but avoids runs of the same author — at each step it takes the highest-ranked remaining item whose author wasn't among the last `window` picks (default 1), falling back to the plain top item when every remaining candidate is a recent author. The #1 ranked item is always chosen first, so "best-fit"/"most-viewed" stays topmost, and each author's items keep their relative ranked order. Applied to createForYouScoreSorter (personalized branch) and createTrendingSorter (live items). Unlike interleaveByAuthor (full round-robin, used only for For You's no-signal fallback + Explore), this preserves ranking rather than flattening it, so each tab keeps its unique ordering process — just de-clustered. Recent (chronological) and Explore (already diversified) are unchanged. Tests: tests/feed-engine/spread-authors.test.mjs (7 — top preserved, no avoidable adjacent dupes, per-author order kept, single-author/short/anon edge cases, window:2 spread). Co-Authored-By: Claude Opus 4.8 --- js/feedEngine/sorters.js | 48 +++++++- tests/feed-engine/spread-authors.test.mjs | 132 ++++++++++++++++++++++ 2 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 tests/feed-engine/spread-authors.test.mjs diff --git a/js/feedEngine/sorters.js b/js/feedEngine/sorters.js index 66b52878..f23bd52c 100644 --- a/js/feedEngine/sorters.js +++ b/js/feedEngine/sorters.js @@ -55,7 +55,9 @@ export function createTrendingSorter() { const live = items.filter((entry) => !isMuted(entry)); const muted = items.filter((entry) => isMuted(entry)); - return [...live.sort(compare), ...muted.sort(compare)]; + // Rank by views, then spread authors so the tab isn't walls of one creator. + // The most-viewed item is still chosen first, so it stays topmost. + return [...spreadAuthors(live.sort(compare)), ...muted.sort(compare)]; }; } @@ -98,6 +100,47 @@ export function interleaveByAuthor(items, { authorOf } = {}) { return result; } +// Gentle author-diversity re-rank. Unlike interleaveByAuthor (full round-robin, +// which flattens the ranking), this keeps the caller's ranked order as the +// priority and only breaks up runs of the same author: at each step it takes the +// highest-ranked remaining item whose author wasn't among the last `window` +// picks, falling back to the plain top item when every remaining candidate is a +// recent author. The #1 ranked item is always chosen first, so "best/most-X on +// top" is preserved while consecutive cards stop clustering by creator. +export function spreadAuthors(items, { authorOf, window: windowSize = 1 } = {}) { + if (!Array.isArray(items) || items.length < 3) { + return Array.isArray(items) ? [...items] : []; + } + const size = Number.isFinite(windowSize) && windowSize > 0 ? Math.floor(windowSize) : 1; + const keyOf = + typeof authorOf === "function" + ? authorOf + : (entry) => + typeof entry?.video?.pubkey === "string" ? entry.video.pubkey : ""; + + const remaining = items.map((item, index) => ({ item, key: keyOf(item), index })); + const result = []; + const recent = []; // authors of the last `size` picks + while (remaining.length) { + let pickIdx = 0; // default: the highest-ranked remaining item + for (let i = 0; i < remaining.length; i += 1) { + const { key } = remaining[i]; + // Anonymous/unknown authors ("") never count as a repeat. + if (!key || !recent.includes(key)) { + pickIdx = i; + break; + } + } + const [picked] = remaining.splice(pickIdx, 1); + result.push(picked.item); + if (picked.key) { + recent.push(picked.key); + if (recent.length > size) recent.shift(); + } + } + return result; +} + // Ranks the For You feed "your people first": followed authors and interest/ // watch-topic matches (forYouTier, set by the scorer) lead regardless of // recency, then everyone else by score. When NOTHING is tiered (logged-out / no @@ -154,6 +197,9 @@ export function createForYouScoreSorter() { if (tierDiff !== 0) return tierDiff; return byScore(a, b); }); + // Break up per-creator clusters (a followed author's whole back-catalogue + // would otherwise sit in one block) while keeping the best-fit item on top. + ordered = spreadAuthors(ordered); } else { // No signals → discovery fallback: score order, then interleave authors so // it doesn't read as a chronological clone of Recently-added. diff --git a/tests/feed-engine/spread-authors.test.mjs b/tests/feed-engine/spread-authors.test.mjs new file mode 100644 index 00000000..5a73db9f --- /dev/null +++ b/tests/feed-engine/spread-authors.test.mjs @@ -0,0 +1,132 @@ +// spreadAuthors() is a gentle diversity re-rank: it keeps the caller's ranked +// order as the priority but breaks up runs of the same creator so a feed tab +// doesn't read as walls of one uploader. The #1 ranked item is always chosen +// first (so "best-fit"/"most-viewed" stays on top), and each author's items keep +// their relative ranked order. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-spread-authors +// given: "a ranked list with clustered authors" +// when: "spreadAuthors re-ranks it (window 1)" +// then: "top item is unchanged, no avoidable adjacent same-author, order-within-author preserved" +// observable_outcomes: +// - "result[0] === input[0] (top rank preserved)" +// - "no two consecutive items share an author when another author remains" +// - "same-author items keep their input relative order" +// determinism_controls: +// - "pure function; fixed input arrays; no clock/network" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "snapshot rubber-stamping"] +// relaxation: +// did_relax_any_assertion: false + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { spreadAuthors } from "../../js/feedEngine/sorters.js"; + +const item = (id, pubkey) => ({ video: { id, pubkey } }); +const ids = (list) => list.map((i) => i.video.id); +const authorsOf = (list) => list.map((i) => i.video.pubkey); + +function hasAvoidableAdjacentDupe(list) { + const authors = authorsOf(list); + const distinct = new Set(authors).size; + if (distinct <= 1) return false; // unavoidable + for (let i = 1; i < authors.length; i += 1) { + if (authors[i] && authors[i] === authors[i - 1]) { + return true; + } + } + return false; +} + +test("keeps the #1 ranked item on top", () => { + const input = [ + item("a1", "A"), + item("a2", "A"), + item("b1", "B"), + item("c1", "C"), + ]; + const out = spreadAuthors(input); + assert.equal(out[0].video.id, "a1", "highest-ranked item stays first"); +}); + +test("breaks up a run of one author into non-adjacent slots", () => { + // Ranked order clusters author A at the top. + const input = [ + item("a1", "A"), + item("a2", "A"), + item("a3", "A"), + item("b1", "B"), + item("c1", "C"), + ]; + const out = spreadAuthors(input); + assert.equal(hasAvoidableAdjacentDupe(out), false, "no avoidable adjacent same-author"); + assert.equal(out.length, input.length, "no items dropped or duplicated"); + assert.deepEqual(ids(out).sort(), ids(input).sort(), "same set of items"); +}); + +test("preserves each author's relative ranked order", () => { + const input = [ + item("a1", "A"), + item("a2", "A"), + item("b1", "B"), + item("a3", "A"), + item("b2", "B"), + ]; + const out = ids(spreadAuthors(input)); + // A's items stay in a1 < a2 < a3 order; B's in b1 < b2 order. + assert.ok(out.indexOf("a1") < out.indexOf("a2"), "a1 before a2"); + assert.ok(out.indexOf("a2") < out.indexOf("a3"), "a2 before a3"); + assert.ok(out.indexOf("b1") < out.indexOf("b2"), "b1 before b2"); +}); + +test("all-one-author input is returned in original order", () => { + const input = [item("a1", "A"), item("a2", "A"), item("a3", "A")]; + assert.deepEqual(ids(spreadAuthors(input)), ["a1", "a2", "a3"]); +}); + +test("lists shorter than 3 are returned unchanged (copy)", () => { + const input = [item("a1", "A"), item("a2", "A")]; + const out = spreadAuthors(input); + assert.deepEqual(ids(out), ["a1", "a2"]); + assert.notEqual(out, input, "returns a copy, not the same reference"); +}); + +test("anonymous authors are allowed to sit adjacent (not treated as repeats)", () => { + const input = [ + item("x1", ""), + item("x2", ""), + item("x3", ""), + ]; + // No real author to diversify against → order preserved, no infinite defer. + assert.deepEqual(ids(spreadAuthors(input)), ["x1", "x2", "x3"]); +}); + +test("window:2 spreads a clustered author across the feed (tail dupes only when forced)", () => { + // A is 50% of the list; a larger window pushes A's items further apart. + const input = [ + item("a1", "A"), + item("a2", "A"), + item("a3", "A"), + item("b1", "B"), + item("c1", "C"), + item("d1", "D"), + ]; + const out = authorsOf(spreadAuthors(input, { window: 2 })); + assert.equal(out[0], "A", "top item preserved"); + // No adjacent dupes (always achievable here since no author exceeds 50%). + for (let i = 1; i < out.length; i += 1) { + assert.notEqual(out[i], out[i - 1], `no adjacent dupe at ${i}`); + } + // A's three items are genuinely spread out, not stacked at the front. + const aPositions = out + .map((author, idx) => (author === "A" ? idx : -1)) + .filter((idx) => idx >= 0); + assert.ok( + aPositions[aPositions.length - 1] - aPositions[0] >= 4, + "A's items span most of the feed rather than clustering", + ); +});