Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/feedthejoe / src/core/rank/rank.js |
| Date | Not specified in source |
| Repository | Janady13/feedthejoe |
| Revision | 0e608a2b515a959bf35d2783428766624400ca3c |
| Branch | main |
| Path or archive member | src/core/rank/rank.js |
| Original SHA-256 | 32335b38bf6afd88fbaf4290f66588e6cf470d36ee85d3e939923dbeb30e43de |
| Git blob SHA | 226db7a8735c02c015e25558b51ead7fa590c9e7 |
| Reading-copy SHA-256 | 32335b38bf6afd88fbaf4290f66588e6cf470d36ee85d3e939923dbeb30e43de |
Recovered source transcript
Download complete source text ↓183 lines · 6.4 KB
/**
* 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<string>} [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: {},
};
}