Files
eranos/src/lib/feedDiversity.ts
T
2026-04-05 20:30:54 -05:00

217 lines
7.0 KiB
TypeScript

import { getKindId } from '@/lib/extraKinds';
import type { FeedItem } from '@/lib/feedUtils';
/** Options for feed diversity reordering. */
export interface DiversifyOptions {
/** Minimum index gap between same content type (default: 4). */
minGap?: number;
/** Maximum proportion of a page any single type can occupy (default: 0.2 = 20%). */
maxProportion?: number;
/** Per-type cap overrides. Key is the content-type ID (from getKindId), value is the proportion. */
typeCaps?: Record<string, number>;
}
/** Default per-type cap overrides. */
const DEFAULT_TYPE_CAPS: Record<string, number> = {
blobbi: 0.1, // 10% cap for Blobbi
};
/** Resolve a kind number to a content-type bucket string. */
function getContentType(kind: number): string {
return getKindId(kind) ?? `kind-${kind}`;
}
/**
* Diversify a single page of feed items for content-type variety.
*
* Two-phase algorithm:
* 1. **Proportional cap** — no single content type may exceed `maxProportion`
* of the page. Excess items (the least-hot ones for that type) are trimmed.
* 2. **Gap-enforced interleave** — items are placed so the same content type
* doesn't appear within `minGap` positions of itself.
*
* @param priorTail - The last `minGap` content types from the previous page,
* so the gap constraint holds across page boundaries. Pass an empty array
* for the first page.
*/
export function diversifyPage(
items: FeedItem[],
priorTail: string[],
options?: DiversifyOptions,
): FeedItem[] {
if (items.length === 0) return items;
const minGap = options?.minGap ?? 4;
const maxProportion = options?.maxProportion ?? 0.2;
const typeCaps = { ...DEFAULT_TYPE_CAPS, ...options?.typeCaps };
// ── Phase 1: Proportional cap ────────────────────────────────────────
const capped = applyCap(items, maxProportion, typeCaps);
// ── Phase 2: Gap-enforced interleave ─────────────────────────────────
return applyGapInterleave(capped, minGap, priorTail);
}
/**
* Diversify multiple pages of feed items incrementally.
*
* Each page is diversified independently but the gap state carries forward
* from the previous page's tail. This ensures:
* - Earlier pages never change when new pages arrive (no visual jumps)
* - The gap constraint holds across page boundaries
* - The proportional cap applies per-page
*/
export function diversifyFeedPages(
pages: FeedItem[][],
options?: DiversifyOptions,
): FeedItem[] {
const minGap = options?.minGap ?? 4;
const result: FeedItem[] = [];
let priorTail: string[] = [];
for (const page of pages) {
const diversified = diversifyPage(page, priorTail, options);
result.push(...diversified);
// Extract the tail content types for the next page's gap tracking.
// We need the last `minGap` types from the combined result so far.
const tailSlice = result.slice(-minGap);
priorTail = tailSlice.map((item) => getContentType(item.event.kind));
}
return result;
}
/**
* Cap each content type to at most `maxProportion` of the page item count.
* Per-type overrides in `typeCaps` take precedence over the default proportion.
* Keeps the hottest items for each type (items are already hot-sorted).
*/
function applyCap(
items: FeedItem[],
maxProportion: number,
typeCaps: Record<string, number>,
): FeedItem[] {
const defaultMax = Math.max(1, Math.ceil(items.length * maxProportion));
/** Resolve the cap for a given content type. */
function maxForType(type: string): number {
const override = typeCaps[type];
if (override !== undefined) {
return Math.max(1, Math.ceil(items.length * override));
}
return defaultMax;
}
const typeCounts = new Map<string, number>();
const result: FeedItem[] = [];
for (const item of items) {
const type = getContentType(item.event.kind);
const count = typeCounts.get(type) ?? 0;
if (count < maxForType(type)) {
result.push(item);
typeCounts.set(type, count + 1);
}
}
return result;
}
/**
* Reorder items so that no two items of the same content type appear
* within `minGap` positions of each other.
*
* @param priorTail - Content type strings from the tail of the previous page,
* used to seed the `lastPlaced` map so the gap holds across boundaries.
*/
function applyGapInterleave(
items: FeedItem[],
minGap: number,
priorTail: string[],
): FeedItem[] {
const result: FeedItem[] = [];
const deferred: FeedItem[] = [];
/** Map from content type → index of last placement in `result`. */
const lastPlaced = new Map<string, number>();
// Seed lastPlaced from the prior page's tail so the gap constraint
// holds across page boundaries. Use negative indices representing
// positions "before" this page's result array.
for (let i = 0; i < priorTail.length; i++) {
const type = priorTail[i];
// The tail items are at virtual indices -(priorTail.length - i)
// relative to the start of this page's result.
const virtualIndex = -(priorTail.length - i);
const existing = lastPlaced.get(type);
// Keep the highest (most recent) index for each type
if (existing === undefined || virtualIndex > existing) {
lastPlaced.set(type, virtualIndex);
}
}
function canPlace(type: string): boolean {
const lastIdx = lastPlaced.get(type);
if (lastIdx === undefined) return true;
return result.length - lastIdx >= minGap;
}
function place(item: FeedItem): void {
const type = getContentType(item.event.kind);
lastPlaced.set(type, result.length);
result.push(item);
}
// Main pass
for (const item of items) {
drainDeferred(deferred, result, lastPlaced, minGap);
const type = getContentType(item.event.kind);
if (canPlace(type)) {
place(item);
} else {
deferred.push(item);
}
}
// Final drain: keep looping until no deferred item can be placed.
// Each iteration tries every item in the queue (not just the front).
for (;;) {
const sizeBefore = deferred.length;
drainDeferred(deferred, result, lastPlaced, minGap);
if (deferred.length === sizeBefore) break; // no progress
}
// Drop anything still deferred rather than clustering same-type items
// at the tail. The cap already limits per-type count; these leftovers
// are items that can't be placed without violating the gap, so it's
// better to omit them than to show three Blobbis in a row.
return result;
}
/**
* Drain one item from the deferred queue whose gap constraint is now satisfied.
*/
function drainDeferred(
deferred: FeedItem[],
result: FeedItem[],
lastPlaced: Map<string, number>,
minGap: number,
): void {
for (let i = 0; i < deferred.length; i++) {
const item = deferred[i];
const type = getContentType(item.event.kind);
const lastIdx = lastPlaced.get(type);
const ok = lastIdx === undefined || result.length - lastIdx >= minGap;
if (ok) {
lastPlaced.set(type, result.length);
result.push(item);
deferred.splice(i, 1);
break;
}
}
}