Files
bitvid/js/feedEngine/exploreScoring.js
T
thePR0M3TH3ANandClaude Opus 4.8 087cf410cb 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 <[email protected]>
2026-06-24 08:50:50 -04:00

604 lines
17 KiB
JavaScript

// js/feedEngine/exploreScoring.js
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
import { isPlainObject, toSet } from "./utils.js";
const DEFAULT_WEIGHTS = Object.freeze({
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;
function clamp01(value) {
if (!Number.isFinite(value)) {
return 0;
}
if (value <= 0) {
return 0;
}
if (value >= 1) {
return 1;
}
return value;
}
function normalizeTagSet(values) {
const normalized = new Set();
const source = toSet(values);
for (const value of source) {
const tag = normalizeHashtag(value);
if (tag) {
normalized.add(tag);
}
}
return normalized;
}
function normalizeTagCountMap(tagCounts) {
const normalized = new Map();
if (tagCounts instanceof Map) {
for (const [tag, count] of tagCounts.entries()) {
const normalizedTag = normalizeHashtag(tag);
if (!normalizedTag) {
continue;
}
const countValue = Number(count);
if (Number.isFinite(countValue) && countValue > 0) {
normalized.set(normalizedTag, countValue);
}
}
return normalized;
}
if (isPlainObject(tagCounts)) {
for (const [tag, count] of Object.entries(tagCounts)) {
const normalizedTag = normalizeHashtag(tag);
if (!normalizedTag) {
continue;
}
const countValue = Number(count);
if (Number.isFinite(countValue) && countValue > 0) {
normalized.set(normalizedTag, countValue);
}
}
}
return normalized;
}
function buildHistoryWeights(tagCounts) {
const weights = new Map();
if (!tagCounts || tagCounts.size === 0) {
return weights;
}
let maxCount = 0;
for (const count of tagCounts.values()) {
if (count > maxCount) {
maxCount = count;
}
}
if (!Number.isFinite(maxCount) || maxCount <= 0) {
return weights;
}
const maxScale = Math.log1p(maxCount);
for (const [tag, count] of tagCounts.entries()) {
const scaled = Math.log1p(count) / maxScale;
if (Number.isFinite(scaled) && scaled > 0) {
weights.set(tag, scaled);
}
}
return weights;
}
function resolveIdfValue(idfMap, tag) {
if (!idfMap || !tag) {
return 1;
}
if (idfMap instanceof Map) {
const value = Number(idfMap.get(tag));
return Number.isFinite(value) && value > 0 ? value : 1;
}
if (isPlainObject(idfMap)) {
const value = Number(idfMap[tag]);
return Number.isFinite(value) && value > 0 ? value : 1;
}
return 1;
}
function buildVideoVector(video, idfMap) {
const vector = new Map();
if (!video || typeof video !== "object") {
return vector;
}
const addTag = (rawTag) => {
if (typeof rawTag !== "string") {
return;
}
const tag = normalizeHashtag(rawTag);
if (!tag) {
return;
}
const idf = resolveIdfValue(idfMap, tag);
const current = vector.get(tag) || 0;
vector.set(tag, current + idf);
};
if (Array.isArray(video.tags)) {
for (const tag of video.tags) {
if (Array.isArray(tag) && tag[0] === "t") {
addTag(tag[1]);
}
}
}
if (Array.isArray(video.nip71?.hashtags)) {
for (const tag of video.nip71.hashtags) {
addTag(tag);
}
}
return vector;
}
function combineUserVector({ interests, disinterests, historyWeights }) {
const vector = new Map();
if (interests && interests.size) {
for (const tag of interests) {
vector.set(tag, (vector.get(tag) || 0) + 1);
}
}
if (disinterests && disinterests.size) {
for (const tag of disinterests) {
vector.set(tag, (vector.get(tag) || 0) - 2);
}
}
if (historyWeights && historyWeights.size) {
for (const [tag, weight] of historyWeights.entries()) {
vector.set(tag, (vector.get(tag) || 0) + weight);
}
}
return vector;
}
function cosineSimilarity(vectorA, vectorB) {
if (!vectorA || !vectorB || vectorA.size === 0 || vectorB.size === 0) {
return 0;
}
let dot = 0;
let normA = 0;
let normB = 0;
for (const value of vectorA.values()) {
normA += value * value;
}
for (const value of vectorB.values()) {
normB += value * value;
}
if (!Number.isFinite(normA) || !Number.isFinite(normB) || normA <= 0 || normB <= 0) {
return 0;
}
const smaller = vectorA.size <= vectorB.size ? vectorA : vectorB;
const larger = smaller === vectorA ? vectorB : vectorA;
for (const [tag, value] of smaller.entries()) {
const other = larger.get(tag);
if (Number.isFinite(other)) {
dot += value * other;
}
}
const similarity = dot / (Math.sqrt(normA) * Math.sqrt(normB));
if (!Number.isFinite(similarity)) {
return 0;
}
if (similarity > 1) {
return 1;
}
if (similarity < -1) {
return -1;
}
return similarity;
}
function resolvePopularityValue(video, item) {
const candidates = [
video?.viewCount,
video?.views,
video?.stats?.views,
video?.stats?.viewCount,
video?.metrics?.views,
item?.metadata?.viewCount,
];
for (const candidate of candidates) {
const value = Number(candidate);
if (Number.isFinite(value) && value >= 0) {
return value;
}
}
return 0;
}
function resolveTimestamp(video) {
const candidates = [
video?.rootCreatedAt,
video?.nip71Source?.created_at,
video?.created_at,
];
for (const candidate of candidates) {
const value = Number(candidate);
if (Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
}
return null;
}
function resolveWeights(options, context) {
const weights = { ...DEFAULT_WEIGHTS };
const overrides = [options?.weights, context?.runtime?.exploreWeights];
for (const candidate of overrides) {
if (!isPlainObject(candidate)) {
continue;
}
for (const key of Object.keys(weights)) {
if (Number.isFinite(candidate[key])) {
weights[key] = candidate[key];
}
}
}
return weights;
}
function resolveFreshnessHalfLife(options, context) {
const candidates = [options?.freshnessHalfLifeDays, context?.runtime?.exploreFreshnessHalfLifeDays];
for (const candidate of candidates) {
const value = Number(candidate);
if (Number.isFinite(value) && value > 0) {
return value;
}
}
return DEFAULT_FRESHNESS_HALF_LIFE_DAYS;
}
function resolvePopularityMax(options, context) {
const candidates = [options?.popularityMax, context?.runtime?.explorePopularityMax];
for (const candidate of candidates) {
const value = Number(candidate);
if (Number.isFinite(value) && value > 0) {
return value;
}
}
return 0;
}
function resolveNow(context) {
const candidate = Number(context?.runtime?.now);
if (Number.isFinite(candidate) && candidate > 0) {
return Math.floor(candidate);
}
return Math.floor(Date.now() / 1000);
}
export function createExploreScorerStage({ stageName = "explore-scorer", ...options } = {}) {
return async function exploreScorerStage(items = [], context = {}) {
if (!Array.isArray(items) || items.length === 0) {
return items;
}
const tagPreferences = context?.runtime?.tagPreferences;
const interests = normalizeTagSet(tagPreferences?.interests);
const disinterests = normalizeTagSet(tagPreferences?.disinterests);
const historyCounts = normalizeTagCountMap(context?.runtime?.watchHistoryTagCounts);
const historyWeights = buildHistoryWeights(historyCounts);
const userVector = combineUserVector({ interests, disinterests, historyWeights });
const idfMap = context?.runtime?.exploreTagIdf;
const weights = resolveWeights(options, context);
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") {
continue;
}
const video = item.video;
if (!video || typeof video !== "object") {
continue;
}
const videoVector = buildVideoVector(video, idfMap);
const videoTags = new Set(videoVector.keys());
const totalVideoWeight = Array.from(videoVector.values()).reduce(
(sum, value) => sum + (Number.isFinite(value) ? value : 0),
0,
);
const historySimilarity = cosineSimilarity(userVector, videoVector);
const historySimilarityPositive = Math.max(0, historySimilarity);
const novelty = historyWeights.size ? clamp01(1 - historySimilarityPositive) : 0;
let newTagFraction = 0;
if (videoTags.size > 0) {
let newCount = 0;
for (const tag of videoTags) {
if (!historyCounts.has(tag)) {
newCount += 1;
}
}
newTagFraction = clamp01(newCount / videoTags.size);
}
let disinterestOverlap = 0;
if (disinterests.size > 0 && videoVector.size > 0 && totalVideoWeight > 0) {
let disinterestWeight = 0;
for (const [tag, weight] of videoVector.entries()) {
if (disinterests.has(tag)) {
disinterestWeight += weight;
}
}
disinterestOverlap = clamp01(disinterestWeight / totalVideoWeight);
}
let popularityNorm = 0;
if (popularityMax > 0) {
const viewCount = resolvePopularityValue(video, item);
const normalized = Math.log1p(viewCount) / Math.log1p(popularityMax);
popularityNorm = clamp01(normalized);
}
let freshness = 0;
const createdAt = resolveTimestamp(video);
if (createdAt && freshnessHalfLifeDays > 0) {
const ageSeconds = Math.max(0, now - createdAt);
const ageDays = ageSeconds / 86400;
const decay = Math.exp(-ageDays / freshnessHalfLifeDays);
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.followedPenalty || 0) * followedMatch,
);
if (!isPlainObject(item.metadata)) {
item.metadata = {};
}
item.metadata.exploreScore = score;
item.metadata.exploreComponents = {
novelty,
newTagFraction,
disinterestOverlap,
historySimilarity,
popularityNorm,
freshness,
followedMatch,
tags: Array.from(videoTags),
};
const videoId = typeof video.id === "string" ? video.id : null;
const pubkey = typeof video.pubkey === "string" ? video.pubkey : null;
const positiveComponents = [
{ key: "novelty", value: novelty },
{ key: "freshness", value: freshness },
{ key: "history-similarity", value: historySimilarityPositive },
{ key: "new-tag-fraction", value: newTagFraction },
{ key: "popularity", value: popularityNorm },
];
let dominantPositive = positiveComponents[0];
for (const component of positiveComponents) {
if (component.value > dominantPositive.value) {
dominantPositive = component;
}
}
if (dominantPositive.value > 0) {
context?.addWhy?.({
stage: stageName,
type: "score",
reason: dominantPositive.key,
value: dominantPositive.value,
score,
videoId,
pubkey,
});
}
if (disinterestOverlap > 0) {
context?.addWhy?.({
stage: stageName,
type: "score",
reason: "disinterest-overlap",
value: disinterestOverlap,
score,
videoId,
pubkey,
});
}
}
return items;
};
}
// For You weights — UNLIKE Explore (which rewards novelty/discovery), For You
// rewards affinity to who you follow and what you like/watch, plus freshness.
const FOR_YOU_WEIGHTS = Object.freeze({
follows: 0.4, // author is in your subscriptions
affinity: 0.35, // cosine similarity to your interests + watch-topic profile
freshness: 0.25, // recency decay
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/
* history, score == freshness and it falls back to "recent". Reuses Explore's
* vector/similarity/freshness helpers; adds a follows-boost.
*/
export function createForYouScorerStage({
stageName = "for-you-scorer",
weights: weightOverrides,
...options
} = {}) {
const weights = {
...FOR_YOU_WEIGHTS,
...(isPlainObject(weightOverrides) ? weightOverrides : {}),
};
return async function forYouScorerStage(items = [], context = {}) {
if (!Array.isArray(items) || items.length === 0) {
return items;
}
const tagPreferences = context?.runtime?.tagPreferences;
const interests = normalizeTagSet(tagPreferences?.interests);
const disinterests = normalizeTagSet(tagPreferences?.disinterests);
const historyCounts = normalizeTagCountMap(context?.runtime?.watchHistoryTagCounts);
const historyWeights = buildHistoryWeights(historyCounts);
const userVector = combineUserVector({ interests, disinterests, historyWeights });
const idfMap = context?.runtime?.exploreTagIdf;
const follows = toSet(context?.runtime?.subscriptionAuthors);
const freshnessHalfLifeDays = resolveFreshnessHalfLife(options, context);
const now = resolveNow(context);
for (const item of items) {
if (!item || typeof item !== "object") {
continue;
}
const video = item.video;
if (!video || typeof video !== "object") {
continue;
}
const videoVector = buildVideoVector(video, idfMap);
const totalVideoWeight = Array.from(videoVector.values()).reduce(
(sum, value) => sum + (Number.isFinite(value) ? value : 0),
0,
);
const affinity = Math.max(0, cosineSimilarity(userVector, videoVector));
let disinterestOverlap = 0;
if (disinterests.size > 0 && videoVector.size > 0 && totalVideoWeight > 0) {
let disinterestWeight = 0;
for (const [tag, weight] of videoVector.entries()) {
if (disinterests.has(tag)) {
disinterestWeight += weight;
}
}
disinterestOverlap = clamp01(disinterestWeight / totalVideoWeight);
}
const followsMatch =
typeof video.pubkey === "string" && follows.has(video.pubkey) ? 1 : 0;
let freshness = 0;
const createdAt = resolveTimestamp(video);
if (createdAt && freshnessHalfLifeDays > 0) {
const ageDays = Math.max(0, now - createdAt) / 86400;
freshness = clamp01(Math.exp(-ageDays / freshnessHalfLifeDays));
}
const score = clamp01(
weights.follows * followsMatch +
weights.affinity * affinity +
weights.freshness * freshness -
weights.disinterestOverlap * disinterestOverlap,
);
if (!isPlainObject(item.metadata)) {
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,
freshness,
disinterestOverlap,
};
const videoId = typeof video.id === "string" ? video.id : null;
const pubkey = typeof video.pubkey === "string" ? video.pubkey : null;
const positives = [
{ key: "follows", value: weights.follows * followsMatch },
{ key: "affinity", value: weights.affinity * affinity },
{ key: "freshness", value: weights.freshness * freshness },
];
let dominant = positives[0];
for (const component of positives) {
if (component.value > dominant.value) {
dominant = component;
}
}
if (dominant.value > 0) {
context?.addWhy?.({
stage: stageName,
type: "score",
reason: dominant.key,
value: dominant.value,
score,
videoId,
pubkey,
});
}
}
return items;
};
}