Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/feedthejoe / src/core/rank/score.js |
| Date | Not specified in source |
| Repository | Janady13/feedthejoe |
| Revision | 0e608a2b515a959bf35d2783428766624400ca3c |
| Branch | main |
| Path or archive member | src/core/rank/score.js |
| Original SHA-256 | 24d0522b3d774c0ee85f0a055c122fff4bac1732c5e1b9180b3c52e3d80b864a |
| Git blob SHA | e789f22526a8be79e394f249733ce721c69b1f05 |
| Reading-copy SHA-256 | 24d0522b3d774c0ee85f0a055c122fff4bac1732c5e1b9180b3c52e3d80b864a |
Recovered source transcript
Download complete source text ↓156 lines · 5.3 KB
/**
* 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);
}