feat(feed): make For You inclusive + ranked by your lists (G1-G5)

The For You feed was chronological, hashtag-interest-EXCLUSIVE, and ignored
follows entirely (docs/feed-algo-audit.md). Now it is inclusive + ranked:

- createForYouScorerStage scores each video: follows-boost (author you follow)
  + interest/watch-topic affinity (cosine vs your interests+watch-history vector)
  + freshness - disinterest penalty. Reuses Explore's vector/similarity helpers.
- createForYouScoreSorter ranks by that score (muted last, recency tiebreak).
- For You pipeline: interests now BOOST not exclude (enforceInterests:false);
  scorer added; chronological sorter replaced. Never empty (falls back to
  recency when you have no follows/interests/history).
- buildForYouFeedRuntime now feeds subscriptionAuthors + watchHistoryTagCounts.

Watched/blocked/muted are still removed (suppression/blacklist/moderation
stages unchanged). Cheat-resistant tests (older boosted items only win via the
boost). Grid renders end-to-end (~1.3s). Weight tuning is a follow-up.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-14 16:57:05 -04:00
1 parent 44bf88cf88
commit c0a8636678
8 files changed
+322 -7

No files matched your search

+48
View File
@@ -3,6 +3,54 @@
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.
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 scoreOf = (entry) => {
const score = Number(entry?.metadata?.forYouScore);
return Number.isFinite(score) ? score : 0;
};
const timestampOf = (entry) => {
const video = entry?.video;
if (!video || typeof video !== "object") {
return Number.NEGATIVE_INFINITY;
}
const root = Number(video.rootCreatedAt);
if (Number.isFinite(root)) return Math.floor(root);
const created = Number(video.created_at);
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);
});
return copy;
};
}
export function createChronologicalSorter({
direction = "desc",
postedAtResolver,