mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
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]>
579 lines
16 KiB
JavaScript
579 lines
16 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.3,
|
|
freshness: 0.25,
|
|
historySimilarity: 0.2,
|
|
newTagFraction: 0.1,
|
|
popularityNorm: 0.1,
|
|
disinterestOverlap: 0.25,
|
|
});
|
|
|
|
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);
|
|
|
|
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 score = clamp01(
|
|
weights.novelty * novelty +
|
|
weights.freshness * freshness +
|
|
weights.historySimilarity * historySimilarityPositive +
|
|
weights.newTagFraction * newTagFraction +
|
|
weights.popularityNorm * popularityNorm -
|
|
weights.disinterestOverlap * disinterestOverlap,
|
|
);
|
|
|
|
if (!isPlainObject(item.metadata)) {
|
|
item.metadata = {};
|
|
}
|
|
|
|
item.metadata.exploreScore = score;
|
|
item.metadata.exploreComponents = {
|
|
novelty,
|
|
newTagFraction,
|
|
disinterestOverlap,
|
|
historySimilarity,
|
|
popularityNorm,
|
|
freshness,
|
|
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
|
|
});
|
|
|
|
/**
|
|
* 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;
|
|
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;
|
|
};
|
|
}
|