/** * Per-item scoring. * * One pure function, four terms, no hidden state. Everything that varies — * the clock, the random source, the tuning constants — arrives as an argument. * That is what makes the ranking reproducible: same inputs, same order, every * time, on any machine. */ import { betaSample, mulberry32, seedFrom } from './random.js'; import { cosine } from '../vector/vector.js'; /** * Default tuning. Every one of these is a decision, so each carries its reason. * @readonly */ export const DEFAULT_CONFIG = Object.freeze({ weights: Object.freeze({ /** Thompson draw on the item's topic. Drives exploration and cold start. */ affinity: 1.0, /** Cosine between the visitor's interest vector and the item. The content match. */ semantic: 1.4, /** Mild recency preference. Small: this is a body of work, not a news feed. */ freshness: 0.25, /** Penalty for having been shown already. Prevents the top item pinning. */ fatigue: 0.9, }), /** * Beta priors. Beta(1,1) is uniform — maximal ignorance, maximally wide * posterior, so an unseen topic draws across the whole range and gets its * shot. Raising these would make the model more confident before it has * earned the right to be. */ priorAlpha: 1, priorBeta: 1, /** * Impressions an item receives before it leaves the exploration pool. * TikTok seeds new content to a small audience and promotes on measured * engagement velocity. At corpus scale we have one visitor, not an audience, * so the analogue is: guarantee every item a few appearances before letting * the model's opinion harden. */ explorationImpressions: 2, /** Score bonus applied while an item is still in the exploration pool. */ explorationBonus: 0.6, /** Half-life for the freshness term, in days. */ freshnessHalfLifeDays: 240, /** Interest-vector learning rate. See emaToward. */ alpha: 0.35, /** * MMR trade-off. 1.0 is pure relevance; 0.0 is pure novelty. 0.72 keeps the * feed coherent while guaranteeing it cannot collapse into one topic — which * is the characteristic failure of a small-corpus recommender. */ diversityLambda: 0.72, }); /** Milliseconds in a day. */ const DAY_MS = 86400000; /** * Exponential recency decay in (0, 1]. * * @param {number} publishedMs Epoch ms. * @param {number} nowMs Epoch ms. Injected, never read from the clock here. * @param {number} halfLifeDays * @returns {number} */ export function freshness(publishedMs, nowMs, halfLifeDays) { if (!Number.isFinite(publishedMs)) return 0.5; const ageDays = Math.max(0, (nowMs - publishedMs) / DAY_MS); return Math.pow(0.5, ageDays / halfLifeDays); } /** * Saturating fatigue penalty in [0, 1). * * Grows fast for the first few impressions then flattens, so the second showing * is heavily penalised but the tenth is not meaningfully worse than the ninth. * A linear penalty would eventually drive well-liked items below noise. * * @param {number} impressions * @returns {number} */ export function fatigue(impressions) { const n = Math.max(0, impressions | 0); return n / (n + 1.5); } /** * Score one item against one visitor state. * * @param {import('../../ports/types.js').Item} item * @param {import('../../ports/types.js').VisitorState} state * @param {object} cfg Tuning, shaped like DEFAULT_CONFIG. * @param {number} nowMs Injected clock. * @param {() => number} rng Injected randomness. * @returns {{total: number, parts: {affinity: number, semantic: number, freshness: number, fatigue: number, exploring: boolean}}} */ export function scoreItem(item, state, cfg, nowMs, rng) { const w = cfg.weights; // --- affinity: a draw from this topic's Beta posterior ------------------- // Sampling rather than taking the mean is the entire exploration mechanism. const post = state.topics[item.topic] ?? { alpha: cfg.priorAlpha, beta: cfg.priorBeta }; const affinity = betaSample(rng, post.alpha, post.beta); // --- semantic: how close is this to what they are actually reading? ------ // cosine is in [-1,1]; map to [0,1] so the term cannot go negative and start // competing with the fatigue penalty for meaning. const sem = state.interest && item.embedding ? (cosine(state.interest, item.embedding) + 1) / 2 : 0.5; const fresh = freshness(item.publishedMs, nowMs, cfg.freshnessHalfLifeDays); const impressions = state.impressions[item.id] ?? 0; const fat = fatigue(impressions); const exploring = impressions < cfg.explorationImpressions; const total = w.affinity * affinity + w.semantic * sem + w.freshness * fresh - w.fatigue * fat + (exploring ? cfg.explorationBonus : 0); return { total, parts: { affinity, semantic: sem, freshness: fresh, fatigue: fat, exploring }, }; } /** * Build a per-item deterministic generator. * * Each item gets randomness derived from (sessionSeed, itemId, round) so the * same item scored twice in one pass draws the same value, while a later round * draws fresh. Without the round term the feed would never re-shuffle; without * the item term, adjacent items would share a draw. * * @param {number} sessionSeed * @param {string} itemId * @param {number} round * @returns {() => number} */ export function rngFor(sessionSeed, itemId, round) { return mulberry32((sessionSeed ^ seedFrom(itemId)) + round * 0x9e3779b9); }