/** * The ranking pipeline. * * rank() is the single entry point to the recommender and the only function in * this package the rest of the application is allowed to call. It is pure: it * reads no clock, opens no storage, touches no DOM, and calls no global random * source. Given the same corpus, state, config, clock and seed it returns the * same order — which is what lets the ordering itself be a test fixture. * * Two stages: * * 1. Score every candidate independently (see scoreItem). * 2. Select greedily with Maximal Marginal Relevance, so each pick trades off * its own score against how similar it already is to what has been picked. * * Stage 2 is what keeps a small corpus from collapsing. With ~60 items and a * confident interest vector, pure score-ordering will return six variations on * one theme and the visitor will conclude there is nothing else here. MMR makes * that outcome structurally impossible. */ import { scoreItem, rngFor, DEFAULT_CONFIG } from './score.js'; import { cosine, emaToward } from '../vector/vector.js'; import { weightOf } from '../signal/signals.js'; export { DEFAULT_CONFIG }; /** * Maximal Marginal Relevance (Carbonell & Goldstein, SIGIR 1998). * * MMR = argmax [ lambda * score(c) - (1 - lambda) * max_sim(c, selected) ] * * @param {Array<{item: object, score: number}>} scored Pre-scored candidates. * @param {number} lambda Relevance/novelty trade-off in [0, 1]. * @param {number} limit How many to return. * @returns {Array<{item: object, score: number}>} */ export function mmrSelect(scored, lambda, limit) { const pool = scored.slice(); const picked = []; const n = Math.min(limit, pool.length); // Normalise scores to [0,1] so lambda trades off two comparable quantities. // Without this the balance would silently depend on the score scale. const totals = pool.map((s) => s.score); const lo = Math.min(...totals); const hi = Math.max(...totals); const span = hi - lo || 1; for (const s of pool) s.normScore = (s.score - lo) / span; while (picked.length < n && pool.length > 0) { let bestIdx = 0; let bestVal = -Infinity; for (let i = 0; i < pool.length; i += 1) { const cand = pool[i]; // Similarity to the most similar already-picked item. let maxSim = 0; if (picked.length > 0 && cand.item.embedding) { for (const p of picked) { if (!p.item.embedding) continue; const sim = cosine(cand.item.embedding, p.item.embedding); if (sim > maxSim) maxSim = sim; } } const val = lambda * cand.normScore - (1 - lambda) * maxSim; if (val > bestVal) { bestVal = val; bestIdx = i; } } picked.push(pool.splice(bestIdx, 1)[0]); } return picked; } /** * Rank a corpus for one visitor. * * @param {object[]} corpus All candidate items. * @param {object} state Visitor state: interest vector, topic posteriors, impressions. * @param {object} [options] * @param {object} [options.config] Tuning; defaults to DEFAULT_CONFIG. * @param {number} [options.nowMs] Injected clock. Required for deterministic output. * @param {number} [options.seed] Session seed. * @param {number} [options.round] Increments per re-rank so the feed can move. * @param {number} [options.limit] Max results. * @param {Set} [options.exclude] Item ids to omit entirely. * @returns {Array<{item: object, score: number, parts: object}>} Ordered, best first. */ export function rank(corpus, state, options = {}) { const cfg = options.config ?? DEFAULT_CONFIG; const nowMs = options.nowMs ?? 0; const seed = options.seed ?? 1; const round = options.round ?? 0; const limit = options.limit ?? corpus.length; const exclude = options.exclude; const scored = []; for (const item of corpus) { if (exclude && exclude.has(item.id)) continue; const rng = rngFor(seed, item.id, round); const { total, parts } = scoreItem(item, state, cfg, nowMs, rng); scored.push({ item, score: total, parts }); } return mmrSelect(scored, cfg.diversityLambda, limit); } /** * Fold one observed event into visitor state, returning new state. * * State is treated as immutable. The caller holds the reference; we never * mutate in place. That is not ceremony — it is what makes the state history * replayable, which is how the tuning playground can scrub backwards through a * session and watch the model change its mind. * * @param {object} state * @param {object} event {kind, itemId, completion?} * @param {object} corpusIndex Map of id -> item. * @param {object} [cfg] * @returns {object} New state. */ export function applyEvent(state, event, corpusIndex, cfg = DEFAULT_CONFIG) { const item = corpusIndex[event.itemId]; if (!item) return state; const w = weightOf(event); if (w === 0) return state; // --- topic posterior ------------------------------------------------------ // A positive signal is evidence toward engagement (alpha); a negative one is // evidence against (beta). The magnitude of the signal is the evidence count, // so a full read moves the posterior further than a glance. const prev = state.topics[item.topic] ?? { alpha: cfg.priorAlpha, beta: cfg.priorBeta }; const topics = { ...state.topics, [item.topic]: w > 0 ? { alpha: prev.alpha + w, beta: prev.beta } : { alpha: prev.alpha, beta: prev.beta - w }, // -w is positive here }; // --- interest vector ------------------------------------------------------ const interest = state.interest && item.embedding ? emaToward(state.interest, item.embedding, cfg.alpha, w) : state.interest; return { ...state, topics, interest }; } /** * Record that items were shown. Kept separate from applyEvent because an * impression is not feedback — conflating "we showed it" with "they liked it" * is the classic way to build a recommender that congratulates itself. * * @param {object} state * @param {string[]} itemIds * @returns {object} New state. */ export function recordImpressions(state, itemIds) { const impressions = { ...state.impressions }; for (const id of itemIds) impressions[id] = (impressions[id] ?? 0) + 1; return { ...state, impressions }; } /** * A fresh visitor: no opinions, no history, uniform priors everywhere. * * @param {number} dim Embedding dimensionality. * @returns {object} */ export function initialState(dim) { return { interest: new Float32Array(dim), topics: {}, impressions: {}, }; }