mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
82 lines
3.0 KiB
JavaScript
82 lines
3.0 KiB
JavaScript
// 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);
|
|
});
|