mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
f05ba5edf9
commit
4873a43135
3 files changed
+203
-17
No files matched your search
@@ -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
@@ -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)];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user