mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
For You shared the full active set with Recent/Explore and only re-ranked by a blended score, so when a user's signals were thin it collapsed toward freshness and looked identical to Recently-added. Give it a structural identity: - The scorer tags each item forYouTier: 2 = followed author, 1 = interest/ watch-topic match, 0 = other. - The sorter leads with tier (your people first) regardless of raw score, then ranks within tier; trusted-muted still sinks last. - When NOTHING is tiered (logged-out / no follows / no interests) it falls back to an author-interleaved discovery order (new interleaveByAuthor) so the feed never reads as a chronological clone of Recently-added. Tests: tests/for-you-tiering.test.mjs (tier beats score, tiers stack, muted last, no-signal interleave, scorer tier tagging). Existing for-you-scorer suite still green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
115 lines
4.2 KiB
JavaScript
115 lines
4.2 KiB
JavaScript
// Scenario (SCN-for-you-tiering): "your people first".
|
|
// Given For You items tagged with forYouTier (2=followed author, 1=interest/
|
|
// watch match, 0=other) and forYouScore,
|
|
// When sorted by createForYouScoreSorter,
|
|
// Then higher tiers lead REGARDLESS of raw score (a followed/interest video
|
|
// outranks a higher-scoring stranger), trusted-muted sinks last, and when
|
|
// NOTHING is tiered the order is author-interleaved (discovery) rather than a
|
|
// straight score/recency clone of Recently-added.
|
|
//
|
|
// Cheat-resistant: tier-2/tier-1 winners are given deliberately LOWER scores than
|
|
// the tier-0 item, so they can only lead via the tier, not the score.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
createForYouScoreSorter,
|
|
interleaveByAuthor,
|
|
} from "../js/feedEngine/sorters.js";
|
|
import { createForYouScorerStage } from "../js/feedEngine/exploreScoring.js";
|
|
|
|
const A = "a".repeat(64);
|
|
const B = "b".repeat(64);
|
|
const C = "c".repeat(64);
|
|
|
|
const item = (id, { tier = 0, score = 0, pubkey = A, muted = false } = {}) => ({
|
|
video: { id, pubkey, created_at: 1000 },
|
|
metadata: {
|
|
forYouTier: tier,
|
|
forYouScore: score,
|
|
...(muted ? { moderation: { trustedMuted: true } } : {}),
|
|
},
|
|
});
|
|
|
|
const order = (items) =>
|
|
createForYouScoreSorter()(items, {}).map((i) => i.video.id);
|
|
|
|
test("a followed (tier 2) video leads a higher-scoring stranger (tier 0)", () => {
|
|
const items = [
|
|
item("stranger", { tier: 0, score: 0.95, pubkey: B }),
|
|
item("followed", { tier: 2, score: 0.1, pubkey: A }),
|
|
];
|
|
assert.deepEqual(order(items), ["followed", "stranger"]);
|
|
});
|
|
|
|
test("tiers stack: follow > interest > other, regardless of score", () => {
|
|
const items = [
|
|
item("other", { tier: 0, score: 0.9, pubkey: C }),
|
|
item("interest", { tier: 1, score: 0.2, pubkey: B }),
|
|
item("followed", { tier: 2, score: 0.05, pubkey: A }),
|
|
];
|
|
assert.deepEqual(order(items), ["followed", "interest", "other"]);
|
|
});
|
|
|
|
test("trusted-muted sinks last even if tiered", () => {
|
|
const items = [
|
|
item("muted-follow", { tier: 2, score: 0.9, pubkey: A, muted: true }),
|
|
item("plain", { tier: 0, score: 0.1, pubkey: B }),
|
|
];
|
|
assert.deepEqual(order(items), ["plain", "muted-follow"]);
|
|
});
|
|
|
|
test("no-signal fallback interleaves authors (not a chronological clone)", () => {
|
|
// All tier 0 (no follows/interests). Author A dominates by count; a straight
|
|
// score/recency sort would clump all A's first. Interleave must break that up.
|
|
const items = [
|
|
item("a1", { tier: 0, score: 0.9, pubkey: A }),
|
|
item("a2", { tier: 0, score: 0.8, pubkey: A }),
|
|
item("a3", { tier: 0, score: 0.7, pubkey: A }),
|
|
item("b1", { tier: 0, score: 0.6, pubkey: B }),
|
|
item("c1", { tier: 0, score: 0.5, pubkey: C }),
|
|
];
|
|
const result = order(items);
|
|
// First three must NOT all be author A (the clumping a pure sort would produce).
|
|
const firstThreeAuthors = result.slice(0, 3).map((id) => id[0]);
|
|
assert.notDeepEqual(firstThreeAuthors, ["a", "a", "a"]);
|
|
// Highest-scored item still leads its pass.
|
|
assert.equal(result[0], "a1");
|
|
// No items lost.
|
|
assert.deepEqual([...result].sort(), ["a1", "a2", "a3", "b1", "c1"]);
|
|
});
|
|
|
|
test("interleaveByAuthor is a no-op for <3 items", () => {
|
|
const items = [item("x", { pubkey: A }), item("y", { pubkey: A })];
|
|
assert.deepEqual(
|
|
interleaveByAuthor(items).map((i) => i.video.id),
|
|
["x", "y"],
|
|
);
|
|
});
|
|
|
|
test("scorer tags forYouTier: 2 for follows, 1 for interest, 0 for neither", async () => {
|
|
const NOW = Math.floor(Date.now() / 1000);
|
|
const mk = (id, pubkey, tags = []) => ({
|
|
video: { id, pubkey, created_at: NOW, tags: tags.map((t) => ["t", t]) },
|
|
metadata: {},
|
|
});
|
|
const items = [
|
|
mk("followed", A, []),
|
|
mk("interested", B, ["nostr"]),
|
|
mk("neither", C, []),
|
|
];
|
|
await createForYouScorerStage()(items, {
|
|
now: NOW,
|
|
runtime: {
|
|
subscriptionAuthors: [A],
|
|
tagPreferences: { interests: ["nostr"], disinterests: [] },
|
|
},
|
|
});
|
|
const tierById = Object.fromEntries(
|
|
items.map((i) => [i.video.id, i.metadata.forYouTier]),
|
|
);
|
|
assert.equal(tierById.followed, 2, "followed author → tier 2");
|
|
assert.equal(tierById.interested, 1, "interest match → tier 1");
|
|
assert.equal(tierById.neither, 0, "no signal → tier 0");
|
|
});
|