diff --git a/js/feedEngine/exploreScoring.js b/js/feedEngine/exploreScoring.js index 01a47250..22b7e368 100644 --- a/js/feedEngine/exploreScoring.js +++ b/js/feedEngine/exploreScoring.js @@ -459,6 +459,11 @@ const FOR_YOU_WEIGHTS = Object.freeze({ disinterestOverlap: 0.3, // penalty for disinterested tags }); +// Affinity at/above this counts as a real interest match (tier 1), so +// interest/watch-topic videos lead the feed alongside follows. Tuned low because +// cosine affinity against a sparse user vector rarely gets near 1. +const FOR_YOU_AFFINITY_TIER = 0.15; + /** * For You scorer (docs/feed-algo-audit.md). Inclusive + ranked: it scores (never * filters) so the feed is never empty — when you have no follows/interests/ @@ -540,6 +545,16 @@ export function createForYouScorerStage({ item.metadata = {}; } item.metadata.forYouScore = score; + // Tier drives "your people first": followed authors (2) and interest/ + // watch-topic matches (1) lead the feed regardless of recency; everything + // else (0) ranks below by score. The sorter reads this; when NO item is + // tiered (logged-out / no follows / no interests) it falls back to a + // discovery ordering so For You never mirrors Recently-added. + item.metadata.forYouTier = followsMatch + ? 2 + : affinity >= FOR_YOU_AFFINITY_TIER + ? 1 + : 0; item.metadata.forYouComponents = { followsMatch, affinity, diff --git a/js/feedEngine/sorters.js b/js/feedEngine/sorters.js index e3a97f63..f0fe060a 100644 --- a/js/feedEngine/sorters.js +++ b/js/feedEngine/sorters.js @@ -3,21 +3,66 @@ import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { markAsNormalized } from "./utils.js"; -// Ranks by the For You score (set by createForYouScorerStage): score desc, -// trusted-muted last, recency as the tiebreak. Reuses the same muted-last rule -// as the chronological sorter so behavior is consistent across feeds. +// Round-robin interleave by author: one video per creator per pass, each pass in +// the caller's pre-sorted order. Consecutive cards are from different creators, +// so a feed reads as a varied "discovery" mix instead of a chronological wall — +// used for For You's no-signal fallback and Explore's diversity. +export function interleaveByAuthor(items, { authorOf } = {}) { + if (!Array.isArray(items) || items.length < 3) { + return Array.isArray(items) ? [...items] : []; + } + const keyOf = + typeof authorOf === "function" + ? authorOf + : (entry) => + typeof entry?.video?.pubkey === "string" ? entry.video.pubkey : ""; + + const buckets = new Map(); + const order = []; + for (const item of items) { + const key = keyOf(item) || `__anon__${order.length}`; + if (!buckets.has(key)) { + buckets.set(key, []); + order.push(key); + } + buckets.get(key).push(item); + } + + const result = []; + let remaining = items.length; + while (remaining > 0) { + for (const key of order) { + const bucket = buckets.get(key); + if (bucket && bucket.length) { + result.push(bucket.shift()); + remaining -= 1; + } + } + } + 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 +// follows / no interests) it falls back to an author-interleaved discovery order +// so the feed never mirrors Recently-added. Trusted-muted always sinks last. export function createForYouScoreSorter() { return function forYouScoreSorter(items = [], context = {}) { if (!Array.isArray(items)) { return []; } void context; - const copy = [...items]; const isMuted = (entry) => entry?.metadata?.moderation?.trustedMuted === true || entry?.video?.moderation?.trustedMuted === true; + const tierOf = (entry) => { + const tier = Number(entry?.metadata?.forYouTier); + return Number.isFinite(tier) ? tier : 0; + }; + const scoreOf = (entry) => { const score = Number(entry?.metadata?.forYouScore); return Number.isFinite(score) ? score : 0; @@ -34,20 +79,32 @@ export function createForYouScoreSorter() { return Number.isFinite(created) ? Math.floor(created) : Number.NEGATIVE_INFINITY; }; - copy.sort((a, b) => { - const aMuted = isMuted(a); - const bMuted = isMuted(b); - if (aMuted !== bMuted) { - return aMuted ? 1 : -1; - } - const scoreDiff = scoreOf(b) - scoreOf(a); - if (scoreDiff !== 0) { - return scoreDiff; - } - return timestampOf(b) - timestampOf(a); - }); + const live = items.filter((entry) => !isMuted(entry)); + const muted = items.filter((entry) => isMuted(entry)); - return copy; + const byScore = (a, b) => { + const scoreDiff = scoreOf(b) - scoreOf(a); + if (scoreDiff !== 0) return scoreDiff; + return timestampOf(b) - timestampOf(a); + }; + + const personalized = live.some((entry) => tierOf(entry) > 0); + + let ordered; + if (personalized) { + // Tier desc, then score within tier. "Your people" rise to the top. + ordered = [...live].sort((a, b) => { + const tierDiff = tierOf(b) - tierOf(a); + if (tierDiff !== 0) return tierDiff; + return byScore(a, b); + }); + } else { + // No signals → discovery fallback: score order, then interleave authors so + // it doesn't read as a chronological clone of Recently-added. + ordered = interleaveByAuthor([...live].sort(byScore)); + } + + return [...ordered, ...muted.sort(byScore)]; }; } diff --git a/tests/for-you-tiering.test.mjs b/tests/for-you-tiering.test.mjs new file mode 100644 index 00000000..8fd1d9df --- /dev/null +++ b/tests/for-you-tiering.test.mjs @@ -0,0 +1,114 @@ +// 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"); +});