mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[feed] Break up per-creator clustering in For You / Trending grids
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 <[email protected]>
This commit is contained in:
1 parent
a1ed04b22c
commit
e3139e8712
2 files changed
+179
-1
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user