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

+39 -4
View File
@@ -34,6 +34,8 @@ export function createFeedCoordinator(deps) {
createDedupeByRootStage,
createExploreDiversitySorter,
createExploreScorerStage,
createForYouScorerStage,
createForYouScoreSorter,
createKidsAudienceFilterStage,
createKidsScorerStage,
createKidsScoreSorter,
@@ -158,9 +160,11 @@ export function createFeedCoordinator(deps) {
return this.feedEngine.registerFeed(FEED_TYPES.FOR_YOU, {
source: createActiveNostrSource({ service: this.nostrService }),
stages: [
// Note: Tag-preference filtering is consolidated in createTagPreferenceFilterStage
// so each feed has a single source of truth for interest-based inclusion/ranking.
createTagPreferenceFilterStage(),
// For You is INCLUSIVE + RANKED (docs/feed-algo-audit.md): interests
// BOOST rather than exclude, so the feed is never empty. We only drop
// disinterested tags here (enforceInterests:false); the ranking by
// follows + interest/watch affinity + freshness happens in the scorer.
createTagPreferenceFilterStage({ enforceInterests: false }),
createBlacklistFilterStage({
shouldIncludeVideo: (video, options) =>
this.nostrService.shouldIncludeVideo(video, options),
@@ -177,8 +181,9 @@ export function createFeedCoordinator(deps) {
trustedReportHideThreshold: resolveThresholdFromApp("trustedSpamHideThreshold"),
}),
createResolvePostedAtStage(),
createForYouScorerStage(),
],
sorter: createChronologicalSorter(),
sorter: createForYouScoreSorter(),
hooks: {
timestamps: {
getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video),
@@ -526,6 +531,34 @@ export function createFeedCoordinator(deps) {
const preferencesAvailable = preferenceSource?.dataReady === true;
const moderationThresholds = this.getActiveModerationThresholds();
// Follows drive the For You ranking (follows-boost in the scorer).
let subscriptionAuthors = [];
try {
if (subscriptions && typeof subscriptions.getSubscribedAuthors === "function") {
const authors = subscriptions.getSubscribedAuthors();
if (Array.isArray(authors)) {
subscriptionAuthors = authors.filter(
(a) => typeof a === "string" && a,
);
}
}
} catch (error) {
devLogger.warn("[feedCoordinator] Failed to read subscribed authors:", error);
}
// Watch-topic affinity signal (reuse the Explore source if present).
const whTagSource =
this.exploreDataService &&
typeof this.exploreDataService.getWatchHistoryTagCounts === "function"
? this.exploreDataService.getWatchHistoryTagCounts()
: this.watchHistoryTagCounts;
const watchHistoryTagCounts =
whTagSource instanceof Map
? new Map(whTagSource)
: whTagSource && typeof whTagSource === "object"
? { ...whTagSource }
: undefined;
const watchedKeys = new Set();
if (Array.isArray(watchHistoryItems)) {
for (const item of watchHistoryItems) {
@@ -542,6 +575,8 @@ export function createFeedCoordinator(deps) {
return {
blacklistedEventIds: blacklist,
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
subscriptionAuthors,
watchHistoryTagCounts,
tagPreferences: {
interests: Array.isArray(interests) ? [...interests] : [],
disinterests: Array.isArray(disinterests) ? [...disinterests] : [],