mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
452 lines
12 KiB
JavaScript
452 lines
12 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;
|
|
};
|
|
}
|