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]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-24 08:46:30 -04:00
1 parent f05ba5edf9
commit 4873a43135
3 files changed
+203 -17

No files matched your search

+15
View File
@@ -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,
+74 -17
View File
@@ -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)];
};
}
+114
View File
@@ -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");
});