mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 01:42:27 +00:00
feat(feed): make For You inclusive + ranked by your lists (G1-G5)
The For You feed was chronological, hashtag-interest-EXCLUSIVE, and ignored follows entirely (docs/feed-algo-audit.md). Now it is inclusive + ranked: - createForYouScorerStage scores each video: follows-boost (author you follow) + interest/watch-topic affinity (cosine vs your interests+watch-history vector) + freshness - disinterest penalty. Reuses Explore's vector/similarity helpers. - createForYouScoreSorter ranks by that score (muted last, recency tiebreak). - For You pipeline: interests now BOOST not exclude (enforceInterests:false); scorer added; chronological sorter replaced. Never empty (falls back to recency when you have no follows/interests/history). - buildForYouFeedRuntime now feeds subscriptionAuthors + watchHistoryTagCounts. Watched/blocked/muted are still removed (suppression/blacklist/moderation stages unchanged). Cheat-resistant tests (older boosted items only win via the boost). Grid renders end-to-end (~1.3s). Weight tuning is a follow-up. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
44bf88cf88
commit
c0a8636678
8 files changed
+322
-7
No files matched your search
@@ -0,0 +1,81 @@
|
||||
// Scenario (SCN-for-you-ranked): the For You feed is inclusive + ranked.
|
||||
// Given videos, a follows set, hashtag interests, and disinterests,
|
||||
// When scored by createForYouScorerStage and sorted by createForYouScoreSorter,
|
||||
// Then a FOLLOWED author and an INTEREST match each rank above an otherwise-
|
||||
// newer item (so the boost — not just recency — decides), a DISINTERESTED
|
||||
// item is penalized below a neutral one, and nothing is filtered out (never
|
||||
// empty).
|
||||
//
|
||||
// Cheat-resistant: the boosted items are deliberately OLDER, so they can only
|
||||
// win via the follows/affinity boost, not recency.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createForYouScorerStage } from "../js/feedEngine/exploreScoring.js";
|
||||
import { createForYouScoreSorter } from "../js/feedEngine/sorters.js";
|
||||
|
||||
const FOLLOWED = "f".repeat(64);
|
||||
const OTHER = "0".repeat(64);
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
const OLD = NOW - 100000; // ~1.16 days older
|
||||
|
||||
const item = (id, pubkey, tagNames = [], createdAt = NOW) => ({
|
||||
video: { id, pubkey, created_at: createdAt, tags: tagNames.map((t) => ["t", t]) },
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
async function rank(items, runtime) {
|
||||
const context = { runtime, now: NOW };
|
||||
await createForYouScorerStage()(items, context);
|
||||
return createForYouScoreSorter()(items, context).map((i) => i.video.id);
|
||||
}
|
||||
|
||||
test("a followed author outranks a newer non-followed video", async () => {
|
||||
const items = [
|
||||
item("newer-stranger", OTHER, [], NOW),
|
||||
item("older-followed", FOLLOWED, [], OLD),
|
||||
];
|
||||
const order = await rank(items, {
|
||||
subscriptionAuthors: [FOLLOWED],
|
||||
tagPreferences: { interests: [], disinterests: [] },
|
||||
});
|
||||
assert.equal(order[0], "older-followed", "follows-boost must beat recency");
|
||||
assert.equal(items.length, 2, "scorer must not filter (never empty)");
|
||||
});
|
||||
|
||||
test("an interest match outranks a newer non-matching video", async () => {
|
||||
const items = [
|
||||
item("newer-sports", OTHER, ["sports"], NOW),
|
||||
item("older-music", OTHER, ["music"], OLD),
|
||||
];
|
||||
const order = await rank(items, {
|
||||
subscriptionAuthors: [],
|
||||
tagPreferences: { interests: ["music"], disinterests: [] },
|
||||
});
|
||||
assert.equal(order[0], "older-music", "interest affinity must beat recency");
|
||||
});
|
||||
|
||||
test("a disinterested tag is penalized below a neutral video", async () => {
|
||||
const items = [
|
||||
item("spammy", OTHER, ["spam"], NOW),
|
||||
item("neutral", OTHER, ["cooking"], NOW),
|
||||
];
|
||||
const order = await rank(items, {
|
||||
subscriptionAuthors: [],
|
||||
tagPreferences: { interests: [], disinterests: ["spam"] },
|
||||
});
|
||||
assert.equal(order[0], "neutral", "disinterested item must rank last");
|
||||
});
|
||||
|
||||
test("with no signals at all, falls back to recency (never empty)", async () => {
|
||||
const items = [
|
||||
item("old", OTHER, [], OLD),
|
||||
item("new", OTHER, [], NOW),
|
||||
];
|
||||
const order = await rank(items, {
|
||||
subscriptionAuthors: [],
|
||||
tagPreferences: { interests: [], disinterests: [] },
|
||||
});
|
||||
assert.equal(order[0], "new", "no signals → newest first");
|
||||
assert.equal(order.length, 2);
|
||||
});
|
||||
Reference in new issue
Block a user