mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
222 lines
7.2 KiB
TypeScript
222 lines
7.2 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
export interface ChannelFrecencyEntry {
|
|
readonly hitCount: number;
|
|
readonly recentHits: ReadonlyArray<number>;
|
|
readonly heat: number;
|
|
readonly score: number;
|
|
}
|
|
|
|
export type ChannelFrecencyHistory = Map<string, ChannelFrecencyEntry>;
|
|
|
|
interface RankedChannelFrecency {
|
|
readonly id: string;
|
|
readonly heat: number;
|
|
}
|
|
|
|
const CHANNEL_FRECENCY_MAX_ITEMS = 100;
|
|
const CHANNEL_FRECENCY_MAX_SAMPLES = 10;
|
|
const CHANNEL_FRECENCY_UNCOMPUTED = -1;
|
|
|
|
const DAY_MS = 86_400_000;
|
|
const MINUTE_MS = 60_000;
|
|
|
|
function createEntry(
|
|
hitCount: number,
|
|
recentHits: ReadonlyArray<number>,
|
|
heat: number,
|
|
score: number,
|
|
): ChannelFrecencyEntry {
|
|
return Object.freeze({hitCount, recentHits: Object.freeze(recentHits), heat, score});
|
|
}
|
|
|
|
function utcOffsetMinutes(timestamp: number): number {
|
|
return -new Date(timestamp).getTimezoneOffset();
|
|
}
|
|
|
|
function compareAscending(a: number, b: number): number {
|
|
return a - b;
|
|
}
|
|
|
|
export function channelFrecencyDayDiff(now: number, then: number): number {
|
|
const zoneDeltaMs = (utcOffsetMinutes(then) - utcOffsetMinutes(now)) * MINUTE_MS;
|
|
return Math.trunc((now - then - zoneDeltaMs) / DAY_MS) || 0;
|
|
}
|
|
|
|
export function channelFrecencyWeight(dayDiff: number): number {
|
|
if (dayDiff === 0) return 100;
|
|
if (dayDiff >= 1 && dayDiff < 2) return 70;
|
|
if (dayDiff >= 2 && dayDiff < 4) return 50;
|
|
if (dayDiff >= 4 && dayDiff < 7) return 30;
|
|
if (dayDiff >= 7) return 10;
|
|
return 1;
|
|
}
|
|
|
|
function scoreRecentUses(recentHits: ReadonlyArray<number>, now: number): number {
|
|
const sampleCount = Math.min(recentHits.length, CHANNEL_FRECENCY_MAX_SAMPLES);
|
|
let score = 0;
|
|
for (let index = 0; index < sampleCount; index++) {
|
|
score += channelFrecencyWeight(channelFrecencyDayDiff(now, recentHits[index]));
|
|
}
|
|
return score;
|
|
}
|
|
|
|
export function trackChannelUse(history: ChannelFrecencyHistory, key: string, timestamp?: number): void {
|
|
const use = timestamp ?? Date.now();
|
|
const entry = history.get(key);
|
|
if (entry === undefined) {
|
|
history.set(key, createEntry(1, [use], CHANNEL_FRECENCY_UNCOMPUTED, 0));
|
|
return;
|
|
}
|
|
const recentHits = [...entry.recentHits, use];
|
|
if (timestamp !== undefined) {
|
|
recentHits.sort(compareAscending);
|
|
}
|
|
history.set(
|
|
key,
|
|
createEntry(
|
|
entry.hitCount + 1,
|
|
recentHits.slice(-CHANNEL_FRECENCY_MAX_SAMPLES),
|
|
CHANNEL_FRECENCY_UNCOMPUTED,
|
|
entry.score,
|
|
),
|
|
);
|
|
}
|
|
|
|
export function computeChannelFrecency(history: ChannelFrecencyHistory, now: number): void {
|
|
for (const [key, entry] of history) {
|
|
if (entry.heat !== CHANNEL_FRECENCY_UNCOMPUTED) continue;
|
|
const score = scoreRecentUses(entry.recentHits, now);
|
|
if (score > 0) {
|
|
const heat = Math.ceil(entry.hitCount * (score / entry.recentHits.length));
|
|
history.set(key, createEntry(entry.hitCount, entry.recentHits, heat, score));
|
|
} else {
|
|
history.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
function findOldestLastUseKey(history: ReadonlyMap<string, ChannelFrecencyEntry>): string | null {
|
|
let oldestKey: string | null = null;
|
|
let oldestLastUse = Number.POSITIVE_INFINITY;
|
|
for (const [key, entry] of history) {
|
|
const lastUse = entry.recentHits.at(-1);
|
|
if (lastUse !== undefined && lastUse < oldestLastUse) {
|
|
oldestKey = key;
|
|
oldestLastUse = lastUse;
|
|
}
|
|
}
|
|
return oldestKey;
|
|
}
|
|
|
|
export function capChannelFrecencyHistory(history: ChannelFrecencyHistory): void {
|
|
while (history.size > CHANNEL_FRECENCY_MAX_ITEMS) {
|
|
const oldestKey = findOldestLastUseKey(history);
|
|
if (oldestKey === null) return;
|
|
history.delete(oldestKey);
|
|
}
|
|
}
|
|
|
|
export function rankFrequentChannelIds(
|
|
history: ReadonlyMap<string, ChannelFrecencyEntry>,
|
|
resolveRecordId: (key: string) => string | null,
|
|
): ReadonlyArray<string> {
|
|
const ranked: Array<RankedChannelFrecency> = [];
|
|
for (const [key, entry] of history) {
|
|
const id = resolveRecordId(key);
|
|
if (id !== null) {
|
|
ranked.push({id, heat: entry.heat});
|
|
}
|
|
}
|
|
ranked.sort((a, b) => b.heat - a.heat);
|
|
return Object.freeze(ranked.slice(0, CHANNEL_FRECENCY_MAX_ITEMS).map((item) => item.id));
|
|
}
|
|
|
|
interface ChannelFrecencyWireEntry {
|
|
readonly totalUses: number;
|
|
readonly recentUsesMs: ReadonlyArray<bigint>;
|
|
}
|
|
|
|
export type ChannelFrecencyWireUsage = Record<string, {totalUses: number; recentUsesMs: Array<bigint>}>;
|
|
|
|
export type ChannelFrecencyWireUsageInput = Readonly<Record<string, ChannelFrecencyWireEntry | undefined>>;
|
|
|
|
function entryToWire(entry: ChannelFrecencyEntry): {totalUses: number; recentUsesMs: Array<bigint>} {
|
|
return {totalUses: entry.hitCount, recentUsesMs: entry.recentHits.map((use) => BigInt(use))};
|
|
}
|
|
|
|
export function channelFrecencyHistoryToWire(
|
|
history: ReadonlyMap<string, ChannelFrecencyEntry>,
|
|
): ChannelFrecencyWireUsage {
|
|
const usage: ChannelFrecencyWireUsage = {};
|
|
for (const [key, entry] of history) {
|
|
usage[key] = entryToWire(entry);
|
|
}
|
|
return usage;
|
|
}
|
|
|
|
function readWireUses(entry: ChannelFrecencyWireEntry | undefined): Array<number> {
|
|
if (entry === undefined) return [];
|
|
return entry.recentUsesMs.map((use) => Number(use)).filter(isUseTimestamp);
|
|
}
|
|
|
|
export function channelFrecencyHistoryFromWire(
|
|
usage: ChannelFrecencyWireUsageInput,
|
|
now: number,
|
|
): ChannelFrecencyHistory {
|
|
const revived: Array<readonly [string, unknown]> = [];
|
|
for (const [key, entry] of Object.entries(usage)) {
|
|
revived.push([key, {hitCount: entry?.totalUses, recentHits: readWireUses(entry)}]);
|
|
}
|
|
return restoreChannelFrecencyHistory(revived, now);
|
|
}
|
|
|
|
export function mergeChannelFrecencyWireUsage(
|
|
local: ChannelFrecencyWireUsageInput,
|
|
incoming: ChannelFrecencyWireUsageInput,
|
|
now: number,
|
|
): ChannelFrecencyWireUsage {
|
|
const merged: ChannelFrecencyHistory = new Map();
|
|
const keys = new Set([...Object.keys(local), ...Object.keys(incoming)]);
|
|
for (const key of keys) {
|
|
const localEntry = local[key];
|
|
const incomingEntry = incoming[key];
|
|
const uses = [...new Set([...readWireUses(localEntry), ...readWireUses(incomingEntry)])];
|
|
if (uses.length === 0) continue;
|
|
uses.sort(compareAscending);
|
|
const totalUses = Math.max(localEntry?.totalUses ?? 0, incomingEntry?.totalUses ?? 0);
|
|
if (!Number.isFinite(totalUses) || totalUses <= 0) continue;
|
|
merged.set(key, createEntry(totalUses, uses.slice(-CHANNEL_FRECENCY_MAX_SAMPLES), CHANNEL_FRECENCY_UNCOMPUTED, 0));
|
|
}
|
|
computeChannelFrecency(merged, now);
|
|
capChannelFrecencyHistory(merged);
|
|
return channelFrecencyHistoryToWire(merged);
|
|
}
|
|
|
|
function isUseTimestamp(value: unknown): value is number {
|
|
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
}
|
|
|
|
function readPersistedEntry(value: unknown): ChannelFrecencyEntry | null {
|
|
if (typeof value !== 'object' || value === null) return null;
|
|
const {hitCount, recentHits} = value as {readonly hitCount?: unknown; readonly recentHits?: unknown};
|
|
if (typeof hitCount !== 'number' || !Number.isFinite(hitCount) || !Array.isArray(recentHits)) return null;
|
|
return createEntry(hitCount, recentHits.filter(isUseTimestamp), CHANNEL_FRECENCY_UNCOMPUTED, 0);
|
|
}
|
|
|
|
export function restoreChannelFrecencyHistory(
|
|
revived: Iterable<readonly [unknown, unknown]>,
|
|
now: number,
|
|
): ChannelFrecencyHistory {
|
|
const history: ChannelFrecencyHistory = new Map();
|
|
for (const [key, value] of revived) {
|
|
if (typeof key !== 'string') continue;
|
|
const entry = readPersistedEntry(value);
|
|
if (entry !== null) {
|
|
history.set(key, entry);
|
|
}
|
|
}
|
|
computeChannelFrecency(history, now);
|
|
return history;
|
|
}
|