From 087cf410cb47cd935adfac37a4b9d4ff365b65ed Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:50:50 -0400 Subject: [PATCH] feed: make Explore "new-to-you + diverse, less recency" Explore shared the full active set with Recent/For You and leaned on freshness, so it read like a reshuffled Recently-added. Give it a discovery identity: - Weights: drop freshness (0.25 -> 0.12) and bump novelty (0.30 -> 0.35) so recency stops dominating and topic-novelty leads. - Add a soft followed-author penalty (weight 0.3): the scorer now reads subscriptionAuthors from the Explore runtime and deprioritizes creators you already follow, so non-followed creators surface ("new to you"). A penalty, not a filter, so the feed stays full if you follow lots of people. - More variety: lower the diversity sorter's MMR lambda (0.7 -> 0.5). Also compacted For You's subscriptionAuthors reader to a one-liner to stay under feedCoordinator's size cap while adding the same reader to Explore. Tests: explore follows-penalty scoring; existing explore-scorer/diversity + feedCoordinator suites green. Co-Authored-By: Claude Opus 4.8 --- js/app/feedCoordinator.js | 26 ++++++++----------- js/feedEngine/exploreScoring.js | 16 +++++++++--- tests/feed-engine/explore-scorer.test.mjs | 31 +++++++++++++++++++++++ 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/js/app/feedCoordinator.js b/js/app/feedCoordinator.js index b461d2d7..b8fefb76 100644 --- a/js/app/feedCoordinator.js +++ b/js/app/feedCoordinator.js @@ -398,7 +398,7 @@ export function createFeedCoordinator(deps) { createResolvePostedAtStage(), createExploreScorerStage(), ], - sorter: createExploreDiversitySorter(), + sorter: createExploreDiversitySorter({ lambda: 0.5 }), hooks: { timestamps: { getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video), @@ -531,20 +531,11 @@ 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); - } + // Follows drive the For You ranking (follows-boost in the scorer). The + // refreshFeed caller wraps this in a try, so optional chaining is enough. + const subscriptionAuthors = ( + subscriptions?.getSubscribedAuthors?.() || [] + ).filter((a) => typeof a === "string" && a); // Watch-topic affinity signal (reuse the Explore source if present). const whTagSource = @@ -671,6 +662,11 @@ export function createFeedCoordinator(deps) { return { blacklistedEventIds: blacklist, isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), + // Explore leans new-to-you: the scorer applies a soft penalty to authors + // you already follow so non-followed creators surface. + subscriptionAuthors: ( + subscriptions?.getSubscribedAuthors?.() || [] + ).filter((a) => typeof a === "string" && a), tagPreferences: { interests: Array.isArray(interests) ? [...interests] : [], disinterests: Array.isArray(disinterests) ? [...disinterests] : [], diff --git a/js/feedEngine/exploreScoring.js b/js/feedEngine/exploreScoring.js index 22b7e368..2ae562f7 100644 --- a/js/feedEngine/exploreScoring.js +++ b/js/feedEngine/exploreScoring.js @@ -4,12 +4,13 @@ import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { isPlainObject, toSet } from "./utils.js"; const DEFAULT_WEIGHTS = Object.freeze({ - novelty: 0.3, - freshness: 0.25, + novelty: 0.35, // reward topics outside your watch history (discovery) + freshness: 0.12, // Explore is discovery, not "what's new" — recency matters less historySimilarity: 0.2, newTagFraction: 0.1, popularityNorm: 0.1, disinterestOverlap: 0.25, + followedPenalty: 0.3, // deprioritize creators you already follow → new-to-you }); const DEFAULT_FRESHNESS_HALF_LIFE_DAYS = 14; @@ -319,6 +320,10 @@ export function createExploreScorerStage({ stageName = "explore-scorer", ...opti const popularityMax = resolvePopularityMax(options, context); const freshnessHalfLifeDays = resolveFreshnessHalfLife(options, context); const now = resolveNow(context); + // Soft penalty for creators you already follow so Explore leans toward + // creators that are NEW to you. A penalty (not a filter) keeps the feed full + // when you follow lots of people. + const follows = toSet(context?.runtime?.subscriptionAuthors); for (const item of items) { if (!item || typeof item !== "object") { @@ -379,13 +384,17 @@ export function createExploreScorerStage({ stageName = "explore-scorer", ...opti freshness = clamp01(decay); } + const followedMatch = + typeof video.pubkey === "string" && follows.has(video.pubkey) ? 1 : 0; + const score = clamp01( weights.novelty * novelty + weights.freshness * freshness + weights.historySimilarity * historySimilarityPositive + weights.newTagFraction * newTagFraction + weights.popularityNorm * popularityNorm - - weights.disinterestOverlap * disinterestOverlap, + weights.disinterestOverlap * disinterestOverlap - + (weights.followedPenalty || 0) * followedMatch, ); if (!isPlainObject(item.metadata)) { @@ -400,6 +409,7 @@ export function createExploreScorerStage({ stageName = "explore-scorer", ...opti historySimilarity, popularityNorm, freshness, + followedMatch, tags: Array.from(videoTags), }; diff --git a/tests/feed-engine/explore-scorer.test.mjs b/tests/feed-engine/explore-scorer.test.mjs index 872a391e..c579e7fc 100644 --- a/tests/feed-engine/explore-scorer.test.mjs +++ b/tests/feed-engine/explore-scorer.test.mjs @@ -158,3 +158,34 @@ test("explore scorer rewards novelty and new tag fraction", async () => { assert.equal(novel.novelty, 1); assert.equal(novel.newTagFraction, 1); }); + +// Explore identity: "new-to-you". A creator you already follow gets a soft +// penalty so non-followed creators surface, without filtering the feed empty. +test("explore soft-penalizes authors you already follow", async () => { + const NOW = Math.floor(Date.now() / 1000); + const FOLLOWED = "f".repeat(64); + const STRANGER = "1".repeat(64); + const tags = [["t", "music"]]; + const items = [ + { video: { id: "stranger", pubkey: STRANGER, created_at: NOW, tags }, metadata: {} }, + { video: { id: "followed", pubkey: FOLLOWED, created_at: NOW, tags }, metadata: {} }, + ]; + + await createExploreScorerStage()(items, { + now: NOW, + runtime: { + subscriptionAuthors: [FOLLOWED], + tagPreferences: { interests: [], disinterests: [] }, + }, + addWhy() {}, + }); + + const strangerScore = items[0].metadata.exploreScore; + const followedScore = items[1].metadata.exploreScore; + assert.ok( + followedScore < strangerScore, + `followed (${followedScore}) must score below an identical stranger (${strangerScore})`, + ); + assert.equal(items[1].metadata.exploreComponents.followedMatch, 1); + assert.equal(items[0].metadata.exploreComponents.followedMatch, 0); +});