Files
bitvid/dist/js/feedEngine/sorters.js
T
2026-01-30 17:09:51 -05:00

461 lines
12 KiB
JavaScript

// js/feedEngine/sorters.js
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
export function createChronologicalSorter({
direction = "desc",
postedAtResolver,
} = {}) {
const normalizedDirection = direction === "asc" ? "asc" : "desc";
return function chronologicalSorter(items = [], context = {}) {
if (!Array.isArray(items)) {
return [];
}
const copy = [...items];
const getTimestamp = (entry) => {
if (!entry || typeof entry !== "object") {
return Number.NEGATIVE_INFINITY;
}
const video = entry.video;
if (!video || typeof video !== "object") {
return Number.NEGATIVE_INFINITY;
}
const resolvePostedAtHook = () => {
if (typeof postedAtResolver === "function") {
try {
const resolved = postedAtResolver({ entry, context, video });
if (Number.isFinite(resolved)) {
return Math.floor(resolved);
}
} catch (error) {
context?.log?.("[chronological-sorter] postedAtResolver threw", error);
}
}
const hook = context?.hooks?.timestamps;
if (!hook || typeof hook !== "object") {
return null;
}
const candidates = [
hook.getKnownVideoPostedAt,
hook.getKnownPostedAt,
hook.getVideoPostedAt,
hook.resolveVideoPostedAt,
];
for (const candidate of candidates) {
if (typeof candidate !== "function") {
continue;
}
try {
const value = candidate(video, { entry, context });
if (Number.isFinite(value)) {
return Math.floor(value);
}
} catch (error) {
context?.log?.(
"[chronological-sorter] timestamps hook threw",
error,
);
}
}
return null;
};
const hookTimestamp = resolvePostedAtHook();
if (Number.isFinite(hookTimestamp)) {
return hookTimestamp;
}
const rootCreatedAt = Number(video.rootCreatedAt);
if (Number.isFinite(rootCreatedAt)) {
return Math.floor(rootCreatedAt);
}
const nip71CreatedAt = Number(video?.nip71Source?.created_at);
if (Number.isFinite(nip71CreatedAt)) {
return Math.floor(nip71CreatedAt);
}
const createdAt = Number(video.created_at);
if (Number.isFinite(createdAt)) {
return Math.floor(createdAt);
}
return Number.NEGATIVE_INFINITY;
};
copy.sort((a, b) => {
const aMuted =
a?.metadata?.moderation?.trustedMuted === true ||
a?.video?.moderation?.trustedMuted === true;
const bMuted =
b?.metadata?.moderation?.trustedMuted === true ||
b?.video?.moderation?.trustedMuted === true;
if (aMuted !== bMuted) {
return aMuted ? 1 : -1;
}
const aTs = getTimestamp(a);
const bTs = getTimestamp(b);
const normalizedATs = Number.isFinite(aTs) ? aTs : Number.NEGATIVE_INFINITY;
const normalizedBTs = Number.isFinite(bTs) ? bTs : Number.NEGATIVE_INFINITY;
if (normalizedATs !== normalizedBTs) {
const diff = normalizedATs - normalizedBTs;
return normalizedDirection === "asc" ? diff : -diff;
}
const aId = typeof a?.video?.id === "string" ? a.video.id : "";
const bId = typeof b?.video?.id === "string" ? b.video.id : "";
return normalizedDirection === "asc"
? aId.localeCompare(bId)
: bId.localeCompare(aId);
});
return copy;
};
}
function buildTagVector(item) {
const vector = new Map();
const video = item?.video;
if (!video || typeof video !== "object") {
return vector;
}
const addTag = (rawTag) => {
if (typeof rawTag !== "string") {
return;
}
const tag = normalizeHashtag(rawTag);
if (!tag) {
return;
}
vector.set(tag, (vector.get(tag) || 0) + 1);
};
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 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 resolveItemTimestamp(item) {
const rootCreatedAt = Number(item?.video?.rootCreatedAt);
if (Number.isFinite(rootCreatedAt)) {
return Math.floor(rootCreatedAt);
}
const createdAt = Number(item?.video?.created_at);
if (Number.isFinite(createdAt)) {
return Math.floor(createdAt);
}
return Number.NEGATIVE_INFINITY;
}
function stableVideoId(item) {
return typeof item?.video?.id === "string" ? item.video.id : "";
}
function compareByTimestampId(a, b) {
const aTs = resolveItemTimestamp(a);
const bTs = resolveItemTimestamp(b);
if (aTs !== bTs) {
return bTs - aTs;
}
return stableVideoId(a).localeCompare(stableVideoId(b));
}
export function createExploreDiversitySorter({
lambda = 0.7,
stageName = "explore-diversity-sorter",
} = {}) {
const normalizedLambda = Number.isFinite(lambda)
? Math.min(1, Math.max(0, lambda))
: 0.7;
return function exploreDiversitySorter(items = [], context = {}) {
if (!Array.isArray(items)) {
return [];
}
const candidates = [];
const rest = [];
for (const item of items) {
const score = item?.metadata?.exploreScore;
if (Number.isFinite(score)) {
candidates.push(item);
} else {
rest.push(item);
}
}
if (candidates.length === 0) {
return [...rest].sort(compareByTimestampId);
}
let maxScore = Number.NEGATIVE_INFINITY;
let minScore = Number.POSITIVE_INFINITY;
for (const item of candidates) {
const score = Number(item?.metadata?.exploreScore);
if (!Number.isFinite(score)) {
continue;
}
if (score > maxScore) {
maxScore = score;
}
if (score < minScore) {
minScore = score;
}
}
const scoreSpan = maxScore - minScore;
const normalizeScore = (score) => {
const numeric = Number(score);
if (!Number.isFinite(numeric)) {
return 0;
}
if (!Number.isFinite(scoreSpan) || scoreSpan <= 0) {
return 1;
}
const normalized = (numeric - minScore) / scoreSpan;
if (normalized <= 0) {
return 0;
}
if (normalized >= 1) {
return 1;
}
return normalized;
};
const vectors = new Map();
for (const item of candidates) {
vectors.set(item, buildTagVector(item));
}
const selected = [];
const remaining = [...candidates];
while (remaining.length > 0) {
let bestCandidate = null;
let bestMmr = Number.NEGATIVE_INFINITY;
let bestRaw = Number.NEGATIVE_INFINITY;
let bestSimilarity = 0;
let bestSimilarItemId = "";
let bestRawCandidate = null;
let bestRawScore = Number.NEGATIVE_INFINITY;
for (const candidate of remaining) {
const rawScore = normalizeScore(candidate?.metadata?.exploreScore);
if (rawScore > bestRawScore) {
bestRawScore = rawScore;
bestRawCandidate = candidate;
} else if (rawScore === bestRawScore && bestRawCandidate) {
const tie = compareByTimestampId(candidate, bestRawCandidate);
if (tie < 0) {
bestRawCandidate = candidate;
}
}
}
for (const candidate of remaining) {
const rawScore = normalizeScore(candidate?.metadata?.exploreScore);
let penalty = 0;
let similarItemId = "";
if (selected.length > 0) {
const candidateVector = vectors.get(candidate);
for (const selectedItem of selected) {
const similarity = cosineSimilarity(
candidateVector,
vectors.get(selectedItem),
);
if (similarity > penalty) {
penalty = similarity;
similarItemId = stableVideoId(selectedItem);
}
}
}
const mmrScore =
normalizedLambda * rawScore - (1 - normalizedLambda) * penalty;
if (mmrScore > bestMmr) {
bestCandidate = candidate;
bestMmr = mmrScore;
bestRaw = rawScore;
bestSimilarity = penalty;
bestSimilarItemId = similarItemId;
continue;
}
if (mmrScore === bestMmr) {
if (rawScore !== bestRaw) {
if (rawScore > bestRaw) {
bestCandidate = candidate;
bestRaw = rawScore;
bestSimilarity = penalty;
bestSimilarItemId = similarItemId;
}
continue;
}
if (compareByTimestampId(candidate, bestCandidate) < 0) {
bestCandidate = candidate;
bestRaw = rawScore;
bestSimilarity = penalty;
bestSimilarItemId = similarItemId;
}
}
}
if (!bestCandidate) {
break;
}
selected.push(bestCandidate);
if (
bestRawCandidate &&
bestRawCandidate !== bestCandidate &&
bestSimilarity > 0
) {
const selectedId = stableVideoId(bestCandidate);
const competitorId = stableVideoId(bestRawCandidate);
if (selectedId && competitorId && selectedId !== competitorId) {
context?.addWhy?.({
stage: stageName,
type: "diversity",
reason: "explore-diversity",
selectedId,
competitorIds: [competitorId],
similarity: bestSimilarity,
similarItemId: bestSimilarItemId || null,
});
}
}
const index = remaining.indexOf(bestCandidate);
if (index >= 0) {
remaining.splice(index, 1);
}
}
const orderedRest = [...rest].sort(compareByTimestampId);
return selected.concat(orderedRest);
};
}
export function createKidsScoreSorter({
stageName = "kids-score-sorter",
} = {}) {
return function kidsScoreSorter(items = [], context = {}) {
if (!Array.isArray(items)) {
return [];
}
const copy = [...items];
copy.sort((a, b) => {
const aScore = Number(a?.metadata?.kidsScore);
const bScore = Number(b?.metadata?.kidsScore);
const aHasScore = Number.isFinite(aScore);
const bHasScore = Number.isFinite(bScore);
const normalizedAScore = aHasScore ? aScore : Number.NEGATIVE_INFINITY;
const normalizedBScore = bHasScore ? bScore : Number.NEGATIVE_INFINITY;
if (normalizedAScore !== normalizedBScore) {
return normalizedBScore - normalizedAScore;
}
const aRootCreatedAt = Number(a?.video?.rootCreatedAt);
const bRootCreatedAt = Number(b?.video?.rootCreatedAt);
const normalizedARoot =
Number.isFinite(aRootCreatedAt) ? aRootCreatedAt : Number.NEGATIVE_INFINITY;
const normalizedBRoot =
Number.isFinite(bRootCreatedAt) ? bRootCreatedAt : Number.NEGATIVE_INFINITY;
if (normalizedARoot !== normalizedBRoot) {
return normalizedBRoot - normalizedARoot;
}
return stableVideoId(a).localeCompare(stableVideoId(b));
});
if (context?.addWhy && typeof context.addWhy === "function") {
context.addWhy({
stage: stageName,
type: "sort",
reason: "kids-score",
});
}
return copy;
};
}