Files
bitvid/tests/for-you-tiering.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 4873a43135 feed: make For You lead with "your people first" (tiering + discovery fallback)
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]>
2026-06-24 08:46:30 -04:00

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");
});